Compare commits

...
Author SHA1 Message Date
rsp2k 74780fef02 Merge fix/consistency: statistics labels, event identity, quiet logging
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
2026-10-01 22:49:12 -06:00
rsp2k 22f2c007e9 Drop the private dev docs hostname from the 2026.01.12 changelog entry
Tests / canary (push) Canceled after 0s
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
2026-10-01 22:49:12 -06:00
rsp2k 7892a5d79f Release 2026.10.01.4: statistics labels, event identity, quiet logging
README and CONTRIBUTING rewritten where they described the old behavior
or code that does not exist: the example test and mixin recipe now run,
MySQL is marked untested, the two Postgres driver paths are spelled out,
and the coverage figure is the measured 95%.
2026-10-01 22:48:46 -06:00
rsp2k c6c928d528 Test statistics labels on SQLite and Postgres, event identity, quiet import 2026-10-01 22:38:21 -06:00
rsp2k 1213b1587e Statistics keyed by enum value, identity resolved before the tool, no logging config on import
get_feedback_statistics and get_database_stats grouped the raw columns,
which hold enum names (BUG, OPEN) on SQLite and Postgres, while
list_feedback returns values (bug, open). Both now count through
count_feedback_by and return values.

The identity resolver ran after the call, so record_event inside a tool
never had user_sub, caller_kind or client_id. It now runs once before
the tool; the call scope carries the result to record_event and the
call's own record reuses it.

Importing the package called logging.basicConfig at INFO. It now adds a
NullHandler to the fastmcp_feedback logger; setup_logging() stays as an
opt-in. PACKAGE_INFO is read from the installed metadata.

psycopg2-binary joins the dev and test extras so the feedback tools can
be tested on Postgres.
2026-10-01 22:36:30 -06:00
19 changed files with 1058 additions and 257 deletions
+58 -1
View File
@@ -5,6 +5,63 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Calendar Versioning](https://calver.org/) (YYYY.MM.DD).
## [2026.10.01.4] - 2026-10-01
Statistics speak the same enum values as the rest of the API, events carry
the caller's identity, and importing the package no longer configures
logging.
### Changed
- `get_feedback_statistics` and `get_database_stats` key `by_type` and
`by_status` by enum value (`bug`, `in_progress`), the spelling
`list_feedback` returns and accepts as a filter. They grouped the raw
columns, which hold the enum NAMES on both SQLite (VARCHAR) and Postgres
(native enum types `feedbacktype`, `feedbackstatus`), so they returned
`BUG`, `IN_PROGRESS`. **Code reading the uppercase keys must switch to
lowercase.** Nothing stored changes. A row holding a value instead of a
name is counted under the same key; an unknown label is kept as-is.
- The identity resolver runs once per call **before** the tool, instead of
after it. The call's record reuses the result (still redacted, still
limited by `hook_timeout`, still never raising). An async resolver can
therefore delay the tool by up to `hook_timeout`, and it now also runs for
ok calls that sampling drops afterwards. It does not run in mode `off` or
for `exclude_tools`. The enricher still runs after the call.
- `PACKAGE_INFO` is read from the installed distribution's metadata. It
listed only the `postgresql` and `dev` extras and a classifier pyproject
did not have.
- psycopg2-binary is in the `dev` and `test` extras, so `make test-pg`
covers the feedback tools on Postgres too.
### Fixed
- `record_event` (and `record_llm_call`) inside a tool call never set
`user_sub`, `caller_kind` or `client_id`, because identity was resolved
only after the call. Inside a call they now come from the call's resolved
identity, matching its row; `client_id` falls back to the FastMCP context's
as for calls. Values passed explicitly win, and `record_event` takes
`caller_kind=` and `client_id=` as well as `user_sub=`. Outside a call,
including a task the call started that records after it returned, nothing
is filled in.
- Importing `fastmcp_feedback` called `logging.basicConfig` at INFO when the
root logger had no handlers, so every host process (the backfill CLI
included) printed an "initialized" line and had its root logger set up by
a library. The package now only adds a `NullHandler` to the
`fastmcp_feedback` logger and logs the version at DEBUG. **Servers that
relied on the package's INFO or WARNING lines must configure logging
themselves**; `fastmcp_feedback.setup_logging()` restores the old
behavior in one line.
- The backfill CLI logs a summary of its counts at INFO, so `-v` shows
something on a dry run.
### Documentation
- README: statistics keys, identity resolver timing, a Logging section, the
two Postgres driver paths (`[postgresql]` for the feedback tools,
`[instrumentation]` plus asyncpg for the sinks), MySQL marked untested,
and the measured coverage (95% lines and branches with the Postgres cases
enabled) in place of "98%".
- CONTRIBUTING: the example test, the mixin recipe and the commands now
match the package and were run; `make test`, `make test-pg` and
`make lint` replace commands that did not pass on this repository.
## [2026.10.01.3] - 2026-10-01
### Documentation
@@ -486,7 +543,7 @@ FastMCP 4 support, and a composition bug that affected every earlier release.
- `create_feedback_server()` for server composition
- Comprehensive test suite (97%+ coverage)
- Full type hints and Pydantic validation
- Documentation site at https://fastmcp-feedback.l.supported.systems
- Documentation site (on a private development address at the time)
### Technical Details
- Built on FastMCP 2.12.2+
+119 -73
View File
@@ -1,6 +1,6 @@
# Contributing to FastMCP Feedback
Thank you for your interest in contributing! This guide will help you get started.
How to set up, test and submit changes.
## Development Setup
@@ -13,17 +13,16 @@ Thank you for your interest in contributing! This guide will help you get starte
### Getting Started
```bash
# 1. Fork the repository on git.supported.systems
# 1. Fork fastmcp-feedback/fastmcp-feedback on git.supported.systems
# 2. Clone your fork
git clone git@git.supported.systems:YOUR_USERNAME/fastmcp-feedback.git
cd fastmcp-feedback
# 3. Create virtual environment and install dependencies
uv venv
uv pip install -e ".[dev]"
# 3. Create .venv from uv.lock with the dev extra
uv sync --extra dev
# 4. Verify setup
uv run pytest
# 4. Verify setup (SQLite only; Postgres cases skip)
make test
```
### Development Workflow
@@ -35,17 +34,14 @@ git checkout -b feature/your-feature-name
# Make your changes...
# Run tests
uv run pytest
make test
# Run linting
uv run ruff check .
uv run ruff format .
# Run type checking (optional but appreciated)
uv run mypy src/fastmcp_feedback
make lint
uv run ruff format --check src/
# Commit with clear message
git commit -m "feat: Add support for custom validators"
git commit -m "Count feedback by submitter in get_feedback_statistics"
# Push and create PR
git push origin feature/your-feature-name
@@ -56,19 +52,21 @@ git push origin feature/your-feature-name
We use [Ruff](https://docs.astral.sh/ruff/) for linting and formatting:
```bash
# Check for issues
uv run ruff check .
# Lint src/, examples/ and the test files that are kept ruff-clean
make lint
# Auto-fix issues
uv run ruff check --fix .
# Format code
uv run ruff format .
# Format what you changed
uv run ruff format src/ path/to/your_test.py
```
Many of the older test files predate the ruff configuration and do not pass
it, so `ruff check .` over the whole repository reports errors that are not
yours. `src/` must stay clean, and new test files should be added to the
`lint` target in the Makefile.
### Style Guidelines
- **Line length**: 88 characters (Black default)
- **Line length**: 88 characters
- **Imports**: Sorted with isort (via Ruff)
- **Type hints**: Required for public APIs
- **Docstrings**: Google style for public functions
@@ -78,11 +76,11 @@ uv run ruff format .
### Running Tests
```bash
# Run all tests
# Run all tests (coverage is on by default, see pyproject.toml)
uv run pytest
# Run with coverage
uv run pytest --cov
# Without the coverage report, as the Makefile does
uv run pytest -q --no-cov
# Run specific test file
uv run pytest tests/unit/test_tools.py
@@ -90,11 +88,8 @@ uv run pytest tests/unit/test_tools.py
# Run specific test
uv run pytest -k "test_submit_feedback"
# Run only unit tests
uv run pytest -m unit
# Run only integration tests
uv run pytest -m integration
# Run one directory
uv run pytest tests/integration
```
### Running the tests against Postgres
@@ -118,7 +113,10 @@ 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.
an empty database, the same one for the same name within a test. The
feedback tools use a synchronous engine, so convert that URL's driver
(`sqlite`, `postgresql+psycopg2`) as the `feedback_db` fixture in
`tests/unit/test_consistency.py` does; psycopg2 comes with the dev extra.
To point the suite at another server, set the variable yourself; the user
needs permission to create and drop databases:
@@ -148,44 +146,60 @@ FFB_TEST_EMBED_URL=https://api.example.com/v1 FFB_TEST_EMBED_KEY=... \
- Use descriptive test names: `test_submit_feedback_with_invalid_type_raises_error`
- Use fixtures from `tests/conftest.py`
Example test:
Tests are async without a marker (`asyncio_mode = "auto"`). Call tools the
way a client does, through `fastmcp.Client`. `submit_feedback` takes one
argument, `request`, holding the fields of `SubmitFeedbackRequest`; `type`
is one of `bug`, `feature`, `improvement` or `question`. Tools report
failure in the result (`success: False` and an `error`) rather than raising.
```python
import pytest
from fastmcp import Client, FastMCP
from fastmcp_feedback import add_feedback_tools
@pytest.mark.unit
async def test_submit_feedback_creates_record(app_with_feedback, db_session):
"""Submitting feedback should create a database record."""
result = await app_with_feedback.call_tool(
"submit_feedback",
type="bug_report",
title="Test Bug",
description="Description"
)
assert result["status"] == "success"
assert result["id"] is not None
async def test_submit_feedback_creates_record(tmp_path):
app = FastMCP("test")
add_feedback_tools(app, database_url=f"sqlite:///{tmp_path}/feedback.db")
async with Client(app) as client:
result = await client.call_tool(
"submit_feedback",
{
"request": {
"type": "bug",
"title": "Export fails",
"description": "Exporting a scene with no objects raises",
"submitter": "test",
}
},
)
listed = await client.call_tool("list_feedback", {})
stats = await client.call_tool("get_feedback_statistics", {})
assert result.structured_content["success"] is True
[item] = listed.structured_content["feedback"]
assert item["id"] == result.structured_content["feedback_id"]
assert (item["type"], item["status"]) == ("bug", "open")
assert stats.structured_content["by_type"] == {"bug": 1}
```
## Pull Request Guidelines
### Before Submitting
1. **Tests pass**: `uv run pytest`
2. **Linting passes**: `uv run ruff check .`
1. **Tests pass**: `make test`, and `make test-pg` when you touched database code
2. **Linting passes**: `make lint`
3. **New features have tests**
4. **Documentation updated** (if applicable)
### PR Title Format
Use [Conventional Commits](https://www.conventionalcommits.org/):
Say what changes, in the imperative, as the commit log does:
- `feat: Add custom validation support`
- `fix: Handle empty feedback list correctly`
- `docs: Update installation instructions`
- `test: Add edge case tests for insights`
- `refactor: Simplify database connection logic`
- `Key feedback statistics by enum value`
- `Resolve caller identity before the tool runs`
- `Import instrumentation without the database extras`
### PR Description Template
@@ -215,16 +229,20 @@ fastmcp-feedback/
│ ├── models.py # Pydantic models and SQLAlchemy ORM
│ ├── database.py # Database connection management
│ ├── feedback.py # Standalone server factory
│ ├── insights.py # Privacy-compliant analytics
│ └── mixins/ # Modular tool mixins
│ ├── base.py
│ ├── submission.py
│ ├── retrieval.py
│ └── management.py
│ ├── insights.py # In-process usage metrics
│ ├── mixins/ # The feedback tools, grouped
│ │ ├── base.py # BaseFeedbackMixin
│ │ ├── submission.py
│ │ ├── retrieval.py
│ │ └── management.py
│ └── instrumentation/ # Middleware, sinks, events, embeddings, backfill
├── tests/
│ ├── conftest.py # Shared fixtures
│ ├── conftest.py # Shared fixtures, including db_backend and db_url
│ ├── _databases.py # Per-test Postgres databases
│ ├── unit/ # Unit tests
│ └── integration/ # Integration tests
├── docker-compose.yml # Postgres + pgvector for make test-pg
├── Makefile
├── pyproject.toml # Package configuration
├── README.md
├── CONTRIBUTING.md # This file
@@ -236,28 +254,56 @@ fastmcp-feedback/
### Adding a New Mixin
1. Create `src/fastmcp_feedback/mixins/your_mixin.py`
2. Inherit from `BaseMixin`
3. Implement `register_tools(app, prefix="")`
4. Export from `src/fastmcp_feedback/mixins/__init__.py`
5. Add to main `__init__.py` exports
6. Write tests in `tests/unit/test_your_mixin.py`
7. Update documentation
A mixin subclasses `BaseFeedbackMixin`, which holds the `FeedbackDatabase`
(`self.database`) and the `FeedbackInsights` (`self.insights`). Its tools are
methods decorated with `mcp_tool` from `fastmcp.contrib.mcp_mixin`;
`register_tools(app, prefix=...)`, inherited from FastMCP's `MCPMixin`, adds
them to a server.
```python
from fastmcp import FastMCP
from fastmcp_feedback import FeedbackDatabase
from fastmcp_feedback.mixins.base import BaseFeedbackMixin, Feedback, mcp_tool
class CountMixin(BaseFeedbackMixin):
@mcp_tool(description="Count feedback items")
async def count_feedback(self) -> dict:
async with self.database.session() as session:
return {"count": session.query(Feedback).count()}
app = FastMCP("demo")
CountMixin(FeedbackDatabase("sqlite:///:memory:")).register_tools(app, prefix="admin")
# The tool is registered as admin_count_feedback.
```
Then:
1. Put it in `src/fastmcp_feedback/mixins/your_mixin.py`
2. Export it from `src/fastmcp_feedback/mixins/__init__.py` and the package `__init__.py`
3. Write tests in `tests/unit/`, calling the tool through `fastmcp.Client`
4. Document it in README.md and on the docs site
### Adding a New Tool
1. Add to appropriate mixin or create new one
2. Use `@app.tool()` decorator with clear description
3. Add Pydantic models for request/response if complex
4. Write unit tests
5. Add integration test
6. Document in README
1. Add a method to the mixin it belongs to, decorated with `mcp_tool` and a
description written for the model that will call it
2. Return a dict; report failures as `{"success": False, "error": ...}`
instead of raising
3. Return enum fields as their values (`bug`, `in_progress`), as
`feedback_to_dict` does; the database stores the names
4. Use a Pydantic model for structured input
5. Test it on both backends where it touches the database (`make test-pg`)
## Versioning
We use **calendar versioning** (CalVer): `YYYY.MM.DD`
Version bumps happen automatically on release. Contributors don't need to update version numbers.
A release bumps the version in `pyproject.toml` and
`src/fastmcp_feedback/__init__.py` and adds a CHANGELOG entry. Contributors
don't need to update version numbers.
## Getting Help
+2 -1
View File
@@ -60,4 +60,5 @@ 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 \
tests/unit/test_embeddings.py tests/unit/test_backfill.py
tests/unit/test_embeddings.py tests/unit/test_backfill.py \
tests/unit/test_consistency.py
+83 -17
View File
@@ -10,10 +10,10 @@ Record every tool call your server handles, and let people and models report pro
- **Secret Redaction** - Tokens and passwords removed from recorded arguments, results and errors by default
- **One-Line Integration** - Add feedback tools to any FastMCP server instantly
- **Modular Mixins** - Selective tool registration for fine-grained control
- **Multi-Database Support** - SQLite (dev), PostgreSQL, MySQL (production)
- **SQLite and PostgreSQL** - Both covered by the test suite. A `mysql` extra exists but MySQL is untested
- **Privacy-Compliant Analytics** - Optional usage insights without sensitive data
- **Type-Safe** - Full Pydantic validation and type hints
- **Well-Tested** - 98% coverage, run against FastMCP 2.12, 2.14, 3.x and 4.x
- **Tested** - 95% line and branch coverage with the Postgres cases enabled (measured for 2026.10.01.4), run against FastMCP 2.12, 2.14, 3.x and 4.x
## Installation
@@ -26,8 +26,11 @@ uv add fastmcp-feedback
# Using pip
pip install fastmcp-feedback
# With PostgreSQL support
# Feedback tools on PostgreSQL (psycopg2, synchronous)
uv add "fastmcp-feedback[postgresql]"
# Instrumentation's DatabaseSink on PostgreSQL (async)
uv add "fastmcp-feedback[instrumentation]" asyncpg
```
What each install covers:
@@ -38,7 +41,11 @@ What each install covers:
| `fastmcp-feedback[instrumentation]` | `sqlalchemy[asyncio]` (greenlet), `aiosqlite` | `DatabaseSink`, `build_metadata`, call history for linking feedback (`capture_recent_calls` across workers, `link_feedback`, `feedback_context`) |
| `fastmcp-feedback[instrumentation,embeddings]` | `pgvector` | `EmbeddingSink`, `OpenAIEmbedder`, `similar()`, the backfill helpers; pgvector is only needed on PostgreSQL |
Postgres hosts add `asyncpg` for the async sinks. Without the
The two database paths use different drivers. The feedback tools run a
synchronous engine, so on Postgres they need `[postgresql]` (psycopg2) and a
`postgresql://` URL. `DatabaseSink` and `EmbeddingSink` run an async engine,
so on Postgres they need `[instrumentation]` plus `asyncpg` and a
`postgresql+asyncpg://` URL. A server that does both installs both. Without the
`instrumentation` extra, `from fastmcp_feedback.instrumentation import
DatabaseSink` raises an `ImportError` that names the missing package and the
extra to install, and the middleware's database helpers return their empty
@@ -96,13 +103,37 @@ add_feedback_tools(app, database_url="sqlite:///feedback.db")
Pass a `database_url`. Without one the tools use an in-memory SQLite
database, which is handy for tests but loses everything when the process exits.
`get_feedback_statistics` keys its counts with the same lowercase values
`list_feedback` returns, so a key can be passed straight back as a filter:
```python
import asyncio
from fastmcp_feedback import FeedbackDatabase, RetrievalMixin, SubmissionMixin
from fastmcp_feedback import SubmitFeedbackRequest
async def main():
db = FeedbackDatabase("sqlite:///:memory:")
await db.initialize()
await SubmissionMixin(db).submit_feedback(SubmitFeedbackRequest(
type="bug", title="Export fails", description="...", submitter="cli"))
stats = await RetrievalMixin(db).get_feedback_statistics()
print(stats["by_type"], stats["by_status"]) # {'bug': 1} {'open': 1}
asyncio.run(main())
```
Before 2026.10.01.4 these keys were the uppercase names the database stores
(`BUG`, `OPEN`). Code that reads them needs the lowercase keys now.
### PostgreSQL
```python
add_feedback_tools(app, database_url="postgresql://user:pass@db.example.com/feedback")
```
Install the driver with `uv add "fastmcp-feedback[postgresql]"`.
Install the driver with `uv add "fastmcp-feedback[postgresql]"`. This is
psycopg2 for the feedback tools' synchronous engine; the instrumentation
sinks use asyncpg instead (see [Installation](#installation)).
### Prefixed Tool Names
@@ -351,6 +382,16 @@ go in `identity`. Enricher output goes in `extra`, except `server_version`,
which has its own column. Async hooks that take longer than `hook_timeout`
(0.25 s by default) are skipped for that call.
The identity resolver runs once per call, **before** the tool. Events the
tool records get the same `user_sub`, `caller_kind` and `client_id` as the
call's row, and the call's record reuses the result instead of asking again.
So keep it cheap: an async resolver can hold the tool back by up to
`hook_timeout`, and it also runs for calls that sampling later drops (sampling
cannot know beforehand whether a call will fail). It does not run in mode
`off` or for `exclude_tools`. The enricher still runs after the call, since it
receives the result. Before 2026.10.01.4 the resolver ran after the call and
events never carried identity.
### Linking feedback to the calls behind it
When a model reports "the render tool hung", the report is far more useful with
@@ -504,8 +545,8 @@ async def submit(case: str) -> dict:
mw.record_event("job.finished", key="job-wing", attrs={"duration_s": 3512, "converged": True})
```
`record_event(kind, *, key=None, attrs=None, user_sub=None, call_id=None)`
returns True when the event was queued and False when it was not. It never
`record_event(kind, *, key=None, attrs=None, user_sub=None, call_id=None,
caller_kind=None, client_id=None)` returns True when the event was queued and False when it was not. It never
raises and never blocks, and it takes the same path as call records: the same
background queue, the same drop-and-count policy when the queue is full.
@@ -522,10 +563,13 @@ background queue, the same drop-and-count policy when the queue is full.
JSON-safe: `NaN` and infinities become the strings `"nan"`/`"inf"` (Postgres
JSONB rejects them), datetimes become ISO strings, anything else unknown
becomes `str(value)`.
- **Inside a tool call**, the event gets that call's id as `call_id` and its
session, so it joins to the call's row in `ffb_tool_calls`. Outside a call,
`call_id` is None. A task started by a tool that records after the tool has
returned counts as outside. Pass `call_id=` to set it yourself.
- **Inside a tool call**, the event gets that call's id as `call_id`, its
session, and the `user_sub`, `caller_kind` and `client_id` the identity
resolver returned for the call, so it joins to the call's row in
`ffb_tool_calls`. Outside a call, those are None unless you pass them. A
task started by a tool that records after the tool has returned counts as
outside. Any of `call_id=`, `user_sub=`, `caller_kind=` and `client_id=`
passed explicitly wins over the call's value.
- **From any thread.** Called off the server's event loop (a watcher thread, a
sync tool running in a worker thread), the event is handed to the loop with
`call_soon_threadsafe`. The loop is known once the server has handled a tool
@@ -862,7 +906,8 @@ python -m fastmcp_feedback.instrumentation.backfill \
```
It prints the counts as JSON and exits 1 if any text failed to embed, 2 on
a setup error. `--create-tables` creates `ffb_embeddings` (and on PostgreSQL
a setup error. It logs warnings to stderr; `-v` adds INFO lines, including
a summary of the counts. `--create-tables` creates `ffb_embeddings` (and on PostgreSQL
the vector extension) when it is missing; without it a missing table is an
error. `--help` lists the rest (`--until`, `--limit`, `--batch-size`,
`--max-chars`, `--event-text-keys`).
@@ -874,6 +919,28 @@ before adding other middleware to include their time and record calls they
reject; call it last to time only the tool. On shutdown, `await mw.aclose()`
flushes queued records and closes the sinks.
## Logging
The package configures no logging. Its loggers sit under `fastmcp_feedback`
with a `NullHandler`, so importing it prints nothing. The middleware never
raises into your tools; when a sink, hook or redactor fails it logs a
WARNING instead, and those warnings only appear once your server configures
logging:
```python
import logging
logging.basicConfig(level=logging.WARNING)
logging.getLogger("fastmcp_feedback").setLevel(logging.INFO) # optional: more detail
```
At INFO the package also reports pruning, table-creation retries and the
servers and tools it sets up. `fastmcp_feedback.setup_logging()` is a
one-line alternative: `basicConfig` at INFO with a timestamped format, then
the package version. Before
2026.10.01.4, importing the package called it automatically whenever the root
logger had no handlers, which also changed the logging of the host process.
## Advanced Usage
### Mixin Architecture
@@ -993,7 +1060,7 @@ uv run python examples/simple_integration.py
## Contributing
We welcome contributions! FastMCP Feedback is designed to be contributor-friendly.
Contributions are welcome.
### Quick Contribution Setup
@@ -1003,15 +1070,14 @@ git clone git@git.supported.systems:YOUR_USERNAME/fastmcp-feedback.git
cd fastmcp-feedback
# Install with dev dependencies
uv venv
uv pip install -e ".[dev]"
uv sync --extra dev
# Create feature branch
git checkout -b feature/your-feature
# Make changes, run tests
uv run pytest
uv run ruff check .
make test
make lint
# Submit PR
```
+4 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "fastmcp-feedback"
version = "2026.10.01.3" # YYYYMMDD.patch versioning - update for each release
version = "2026.10.01.4" # YYYYMMDD.patch versioning - update for each release
description = "Production-ready feedback collection system for FastMCP servers"
readme = "README.md"
license = "MIT"
@@ -59,6 +59,8 @@ dev = [
"sqlalchemy[asyncio]>=2.0.43",
"aiosqlite>=0.22.1",
"asyncpg>=0.31.0",
# The feedback tools' synchronous engine on Postgres, for test-pg.
"psycopg2-binary>=2.9.0",
"pgvector>=0.5.0",
"pytest>=8.2.0",
"pytest-asyncio>=0.23.0",
@@ -72,6 +74,7 @@ test = [
"sqlalchemy[asyncio]>=2.0.43",
"aiosqlite>=0.22.1",
"asyncpg>=0.31.0",
"psycopg2-binary>=2.9.0",
"pgvector>=0.5.0",
"pytest>=8.2.0",
"pytest-asyncio>=0.23.0",
+64 -56
View File
@@ -1,20 +1,18 @@
"""FastMCP Feedback - Comprehensive feedback collection system for FastMCP servers.
"""FastMCP Feedback: feedback tools and call instrumentation for FastMCP servers.
This package provides a complete feedback collection and management system
that integrates seamlessly with FastMCP servers. It supports multiple
integration patterns, privacy-compliant analytics, and production-ready
database management.
Adds tools for submitting, listing and managing feedback (bug reports,
feature requests, improvements, questions) to a FastMCP server, stored in
SQLite or PostgreSQL through SQLAlchemy, with optional in-process usage
metrics. ``fastmcp_feedback.instrumentation`` records every tool call and
the events you report, so a feedback item can be traced back to the calls
that led to it.
Key Features:
- Modular mixin architecture for selective tool integration
- Privacy-compliant analytics with configurable data retention
- Support for SQLite (development) and PostgreSQL (production)
- Comprehensive error handling and logging
- Multiple server composition patterns
- Production-ready with connection pooling and health checks
The package does not configure logging. Its loggers live under
``fastmcp_feedback``; configure them in your server, or call
``setup_logging()`` for a basic stderr handler.
Quick Start:
Basic integration with existing FastMCP server:
Add the feedback tools to an existing server:
>>> from fastmcp import FastMCP
>>> from fastmcp_feedback import add_feedback_tools
@@ -22,19 +20,18 @@ Quick Start:
>>> app = FastMCP("My App")
>>> add_feedback_tools(app, database_url="sqlite:///feedback.db")
Create dedicated feedback server:
A dedicated feedback server:
>>> from fastmcp_feedback import create_feedback_server
>>>
>>> feedback_server = create_feedback_server("Feedback Service")
>>> # feedback_server now has all feedback tools ready
Selective tool integration using mixins:
Only some of the tools, through the mixins:
>>> from fastmcp_feedback import SubmissionMixin, RetrievalMixin
>>> from fastmcp_feedback import SubmissionMixin
>>> from fastmcp_feedback import FeedbackDatabase, FeedbackInsights
>>>
>>> db = FeedbackDatabase("postgresql://user:pass@host:5432/db")
>>> db = FeedbackDatabase("sqlite:///feedback.db")
>>> insights = FeedbackInsights(enabled=True, retention_days=90)
>>>
>>> submission = SubmissionMixin(db, insights)
@@ -44,7 +41,7 @@ Quick Start:
import logging
# Version information - CalVer YYYY.MM.DD
__version__ = "2026.10.01.3"
__version__ = "2026.10.01.4"
__author__ = "Ryan Malloy"
__email__ = "ryan@supported.systems"
@@ -142,38 +139,42 @@ from .tools import (
validate_tool_configuration,
)
# Package metadata
PACKAGE_INFO = {
"name": "fastmcp-feedback",
"version": __version__,
"description": "Comprehensive feedback collection system for FastMCP servers",
"author": __author__,
"email": __email__,
"keywords": ["fastmcp", "feedback", "mcp", "analytics", "database"],
"classifiers": [
"Development Status :: 5 - Production/Ready",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: Software Development :: Libraries :: Python Modules",
"Topic :: Database :: Database Engines/Servers",
"Topic :: Internet :: WWW/HTTP :: Dynamic Content",
],
"dependencies": ["fastmcp>=2.12.2", "sqlalchemy>=2.0.43", "pydantic>=2.11.7"],
"optional_dependencies": {
"postgresql": ["psycopg2-binary>=2.9.0"],
"dev": [
"pytest>=7.4.0",
"pytest-asyncio>=0.21.0",
"pytest-html>=3.2.0",
"pytest-cov>=4.1.0",
"ruff>=0.1.0",
],
},
}
def _package_info() -> dict:
"""Name, version, dependencies and extras from the installed metadata.
Read from the distribution rather than written out here, so it cannot
drift from pyproject.toml.
"""
import re
from importlib.metadata import PackageNotFoundError, metadata
info = {
"name": "fastmcp-feedback",
"version": __version__,
"author": __author__,
"email": __email__,
}
try:
meta = metadata("fastmcp-feedback")
except PackageNotFoundError:
return info
requires = meta.get_all("Requires-Dist") or []
extras = {}
for extra in meta.get_all("Provides-Extra") or []:
marker = re.compile(rf"""extra\s*==\s*['"]{re.escape(extra)}['"]""")
extras[extra] = [r.split(";")[0].strip() for r in requires if marker.search(r)]
info.update(
description=meta["Summary"],
keywords=[k.strip() for k in (meta["Keywords"] or "").split(",") if k],
classifiers=meta.get_all("Classifier") or [],
dependencies=[r for r in requires if "extra ==" not in r],
optional_dependencies=extras,
)
return info
PACKAGE_INFO = _package_info()
# Public API - these are the main exports users should use
__all__ = [
@@ -275,11 +276,18 @@ create_multi_tenant_tools(app, tenants)
""",
}
# Logging configuration
# Logging. A library leaves configuration to the application; the
# NullHandler keeps "No handlers could be found" noise away without
# printing anything.
logging.getLogger("fastmcp_feedback").addHandler(logging.NullHandler())
def setup_logging(level: str = "INFO") -> None:
"""Set up logging for FastMCP Feedback.
"""Send log records to stderr through ``logging.basicConfig``.
Opt-in: importing the package configures nothing. Call this when your
server has no logging set up and you want the package's messages.
``basicConfig`` does nothing if the root logger already has handlers.
Args:
level: Logging level (DEBUG, INFO, WARNING, ERROR).
@@ -293,9 +301,9 @@ def setup_logging(level: str = "INFO") -> None:
logger.info(f"FastMCP Feedback v{__version__} initialized")
# Auto-setup logging if not already configured
if not logging.getLogger().handlers:
setup_logging()
logging.getLogger("fastmcp_feedback").debug(
"FastMCP Feedback v%s initialized", __version__
)
def get_version_info() -> dict:
+37 -13
View File
@@ -3,14 +3,15 @@
import logging
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from enum import Enum
from typing import Any
from sqlalchemy import create_engine, text
from sqlalchemy import String, create_engine, func, select, text, type_coerce
from sqlalchemy.exc import OperationalError, SQLAlchemyError
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import QueuePool, StaticPool
from .models import Base
from .models import Base, Feedback, FeedbackStatus, FeedbackType
logger = logging.getLogger(__name__)
@@ -171,6 +172,38 @@ class FeedbackDatabase:
# Database utility functions
def enum_label(enum_cls: type[Enum], stored: Any) -> str:
"""The API spelling (``bug``, ``in_progress``) of a stored enum value.
SQLAlchemy stores enum members by NAME (``BUG``, ``IN_PROGRESS``): a
VARCHAR on SQLite, a native enum type on Postgres. The tools return
values. Anything that is neither a member, a name nor a value is
returned as a string rather than dropped.
"""
if isinstance(stored, enum_cls):
return str(stored.value)
stored = str(stored)
if stored in enum_cls.__members__:
return str(enum_cls[stored].value)
return stored
def count_feedback_by(session: Session, field: str) -> dict[str, int]:
"""Feedback counts per ``type`` or ``status``, keyed by enum value."""
column, enum_cls = {
"type": (Feedback.type, FeedbackType),
"status": (Feedback.status, FeedbackStatus),
}[field]
# type_coerce: read the raw stored label, so a row the enum cannot load
# is still counted instead of failing the whole query.
raw = type_coerce(column, String)
counts: dict[str, int] = {}
for stored, n in session.execute(select(raw, func.count()).group_by(raw)):
label = enum_label(enum_cls, stored)
counts[label] = counts.get(label, 0) + n
return counts
async def create_feedback_database(database_url: str | None = None) -> FeedbackDatabase:
"""Create and initialize a feedback database.
@@ -200,17 +233,8 @@ async def get_database_stats(db: FeedbackDatabase) -> dict[str, Any]:
total_result = session.execute(text("SELECT COUNT(*) FROM feedback"))
total_count = total_result.scalar()
# Get counts by type
type_result = session.execute(
text("SELECT type, COUNT(*) FROM feedback GROUP BY type")
)
by_type = dict(type_result.fetchall())
# Get counts by status
status_result = session.execute(
text("SELECT status, COUNT(*) FROM feedback GROUP BY status")
)
by_status = dict(status_result.fetchall())
by_type = count_feedback_by(session, "type")
by_status = count_feedback_by(session, "status")
return {
"total_count": total_count,
@@ -298,7 +298,9 @@ async def backfill_embeddings(
raise RuntimeError(f"{db.events.name} does not exist")
if not run.done:
await _backfill_events(run, since, until, batch_size)
return run.result()
result = run.result()
logger.info("Backfill %s: %s", "dry run" if dry_run else "done", result)
return result
async def backfill_feedback(
@@ -127,8 +127,8 @@ def main(argv: list[str] | None = None) -> int:
args = parser.parse_args(argv)
if not args.dry_run and not args.embed_base_url:
parser.error("--embed-base-url is required unless --dry-run")
# force: importing fastmcp_feedback already configured the root logger
# at INFO, which would print every embedding request.
# The CLI owns logging; the package configures none. force: replace
# anything a host process (or a test runner) already set up.
logging.basicConfig(
level=logging.INFO if args.verbose else logging.WARNING,
format="%(levelname)s %(name)s: %(message)s",
@@ -59,12 +59,18 @@ class CallScope:
Tasks started inside a tool inherit a copy of the context and outlive the
call, so the scope is switched off when the call ends instead of relying
on the ContextVar reset alone.
``user_sub``, ``caller_kind`` and ``client_id`` are the call's identity,
resolved before the tool runs, so events recorded inside it carry them.
"""
id: str
session_id: str | None
active: bool = True
events: int = 0
user_sub: str | None = None
caller_kind: str | None = None
client_id: str | None = None
def jsonable(value: Any, depth: int = 0) -> Any:
@@ -14,7 +14,7 @@ import uuid
from collections import deque
from collections.abc import Awaitable, Callable, Iterable, Mapping, Sequence
from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, NamedTuple
from fastmcp import FastMCP
from fastmcp.server.middleware import Middleware, MiddlewareContext
@@ -140,7 +140,11 @@ class InstrumentationMiddleware(Middleware):
meta_only_tools: tools that are never recorded above ``meta``.
identity_resolver: ``(context) -> dict`` returning ``user_sub``,
``caller_kind`` and ``client_id`` (stored as columns) plus any
other keys (stored in ``identity``). May be async.
other keys (stored in ``identity``). May be async. Runs once per
recorded call, before the tool, so events recorded during the
call get the same identity. It also runs for calls that sampling
later drops, and an async one can delay the tool by up to
``hook_timeout``; keep it cheap.
enricher: ``(tool, args, result, context) -> dict`` of extra fields,
stored in ``extra``. A ``server_version`` key fills that column.
May be async.
@@ -257,8 +261,9 @@ class InstrumentationMiddleware(Middleware):
if _tool_name(context) in self.exclude_tools:
return await call_next(context)
started_at = datetime.now(UTC)
start = time.perf_counter()
# Resolved before the tool runs, so events recorded inside it carry
# the caller; the call's own record reuses it.
who = await self._resolve_identity(context)
result = None
error: BaseException | None = None
scope = token = None
@@ -266,11 +271,17 @@ class InstrumentationMiddleware(Middleware):
# The record id exists before the tool runs, so events recorded
# inside it carry the id the stored call row will have.
scope = CallScope(
id=str(uuid.uuid4()), session_id=session_key(context.fastmcp_context)
id=str(uuid.uuid4()),
session_id=session_key(context.fastmcp_context),
user_sub=who.columns.get("user_sub"),
caller_kind=who.columns.get("caller_kind"),
client_id=who.columns.get("client_id"),
)
token = self._call_scope.set(scope)
except Exception:
logger.warning("Failed to open call scope", exc_info=True)
started_at = datetime.now(UTC)
start = time.perf_counter()
try:
result = await call_next(context)
return result
@@ -287,7 +298,7 @@ class InstrumentationMiddleware(Middleware):
self._call_scope.reset(token)
try:
await self._record(
context, started_at, duration_ms, result, error, scope
context, started_at, duration_ms, result, error, scope, who
)
except Exception:
logger.warning("Failed to record tool call", exc_info=True)
@@ -299,7 +310,8 @@ class InstrumentationMiddleware(Middleware):
duration_ms: float,
result: Any,
error: BaseException | None,
scope: CallScope | None = None,
scope: CallScope | None,
who: "_Identity",
) -> None:
tool = _tool_name(context)
payload = _result_payload(result) if result is not None else None
@@ -316,13 +328,30 @@ class InstrumentationMiddleware(Middleware):
elif rate is not None and not self.random() < rate:
return
record = await self._build_record(
context, started_at, duration_ms, payload, error, soft, rate
context, started_at, duration_ms, payload, error, soft, rate, who
)
if scope is not None:
record.id = scope.id
self.recent.append(record)
self.dispatcher.submit(record)
async def _resolve_identity(self, context: MiddlewareContext) -> "_Identity":
"""Run the identity resolver once for a call. Never raises.
Returns the redacted result split into the three columns and the
rest. ``client_id`` falls back to the FastMCP context's.
"""
try:
identity = await self._call_hook(self.identity_resolver, context)
identity = self.redactor.redact(identity)
columns = {k: identity.pop(k) for k in _IDENTITY_COLUMNS if k in identity}
if not columns.get("client_id"):
columns["client_id"] = _attr(context.fastmcp_context, "client_id")
return _Identity(columns, identity)
except Exception:
logger.warning("Failed to resolve identity", exc_info=True)
return _Identity({}, {})
async def _call_hook(self, hook: Callable | None, *args: Any) -> dict[str, Any]:
if hook is None:
return {}
@@ -360,16 +389,16 @@ class InstrumentationMiddleware(Middleware):
error: BaseException | None,
soft: str | None,
sample_rate: float | None,
who: "_Identity",
) -> ToolCallRecord:
tool = _tool_name(context)
args = getattr(context.message, "arguments", None) or {}
ctx = context.fastmcp_context
identity = await self._call_hook(self.identity_resolver, context)
columns = who.columns
identity = dict(who.rest)
extra = await self._call_hook(self.enricher, tool, args, payload, context)
identity = self.redactor.redact(identity)
extra = self.redactor.redact(extra)
columns = {k: identity.pop(k) for k in _IDENTITY_COLUMNS if k in identity}
server_version = extra.pop("server_version", None) or self.server_version
mode = "meta" if tool in self.meta_only_tools else self.mode
@@ -403,7 +432,7 @@ class InstrumentationMiddleware(Middleware):
error_message=error_message,
session_id=session_key(ctx),
request_id=_attr(ctx, "request_id"),
client_id=columns.get("client_id") or _attr(ctx, "client_id"),
client_id=columns.get("client_id"),
user_sub=columns.get("user_sub"),
caller_kind=columns.get("caller_kind"),
identity=identity,
@@ -426,11 +455,16 @@ class InstrumentationMiddleware(Middleware):
attrs: Mapping[str, Any] | None = None,
user_sub: str | None = None,
call_id: str | None = None,
caller_kind: str | None = None,
client_id: str | None = None,
) -> bool:
"""Record something that is not a tool call. Never raises or blocks.
Safe from any thread. Inside a tool call the event gets that call's id
and session; ``call_id`` given here wins. ``attrs`` is redacted like
Safe from any thread. Inside a tool call the event gets that call's
id, session, and the ``user_sub``, ``caller_kind`` and ``client_id``
the identity resolver gave the call; each of those given here wins.
Outside a call (including a task the call started that is still
running after it returned) only what is passed is stored. ``attrs`` is redacted like
call arguments and made JSON-safe. ``kind`` must be a non-empty string
of at most 128 characters and ``key`` at most 255 once made a string.
A ``key`` used for joining must stay unique for the retention period
@@ -468,6 +502,12 @@ class InstrumentationMiddleware(Middleware):
session_id = scope.session_id
if call_id is None:
call_id = scope.id
if user_sub is None:
user_sub = scope.user_sub
if caller_kind is None:
caller_kind = scope.caller_kind
if client_id is None:
client_id = scope.client_id
record = EventRecord(
kind=kind,
occurred_at=datetime.now(UTC),
@@ -475,6 +515,8 @@ class InstrumentationMiddleware(Middleware):
call_id=None if call_id is None else str(call_id),
session_id=session_id,
user_sub=None if user_sub is None else str(user_sub),
caller_kind=None if caller_kind is None else str(caller_kind),
client_id=None if client_id is None else str(client_id),
server_version=self.server_version,
attrs=jsonable(self.redactor.redact(dict(attrs or {}))),
)
@@ -785,6 +827,13 @@ class InstrumentationMiddleware(Middleware):
await self.dispatcher.aclose()
class _Identity(NamedTuple):
"""One call's resolved identity: the column values and everything else."""
columns: dict[str, str | None]
rest: dict[str, Any]
def _is_sink(sink: Any, module: str, name: str) -> bool:
"""isinstance against a sink class without importing its module.
+9 -12
View File
@@ -5,6 +5,7 @@ from typing import Any
from sqlalchemy import text
from ..database import count_feedback_by
from ..insights import record_feedback_retrieval, record_statistics_view
from .base import BaseFeedbackMixin, Feedback, feedback_to_dict, logger, mcp_tool
@@ -93,7 +94,10 @@ class RetrievalMixin(BaseFeedbackMixin):
@mcp_tool(description="Get feedback statistics and analytics")
async def get_feedback_statistics(self) -> dict[str, Any]:
"""Get comprehensive feedback statistics.
"""Feedback counts: total, per type, per status, and the last 7 days.
``by_type`` and ``by_status`` are keyed by the same lowercase values
``list_feedback`` returns (``bug``, ``in_progress``).
Returns:
Dictionary with statistics.
@@ -106,17 +110,10 @@ class RetrievalMixin(BaseFeedbackMixin):
total_result = session.execute(text("SELECT COUNT(*) FROM feedback"))
total_count = total_result.scalar() or 0
# Count by type
type_result = session.execute(
text("SELECT type, COUNT(*) FROM feedback GROUP BY type")
)
by_type = dict(type_result.fetchall()) if total_count > 0 else {}
# Count by status
status_result = session.execute(
text("SELECT status, COUNT(*) FROM feedback GROUP BY status")
)
by_status = dict(status_result.fetchall()) if total_count > 0 else {}
# Keyed by enum value ("bug", "open"), as list_feedback
# returns them; the database stores the names.
by_type = count_feedback_by(session, "type")
by_status = count_feedback_by(session, "status")
# Recent count (last 7 days)
week_ago = datetime.now(UTC) - timedelta(days=7)
+4 -3
View File
@@ -248,9 +248,10 @@ class TestDemoServerIntegration:
stats = stats_result.structured_content
assert stats["total_count"] >= 1
assert "FEATURE" in stats["by_type"]
assert stats["by_type"]["FEATURE"] >= 1
assert "IN_PROGRESS" in stats["by_status"]
# Keyed by the values list_feedback returns, not the stored names.
assert "feature" in stats["by_type"]
assert stats["by_type"]["feature"] >= 1
assert "in_progress" in stats["by_status"]
# Delete feedback
delete_tool = tools["delete_feedback"]
+8 -8
View File
@@ -61,10 +61,10 @@ class TestCompleteWorkflow:
stats = stats_result.structured_content
assert stats["total_count"] >= 1
assert "BUG" in stats["by_type"]
assert stats["by_type"]["BUG"] >= 1
assert "OPEN" in stats["by_status"]
assert stats["by_status"]["OPEN"] >= 1
assert "bug" in stats["by_type"]
assert stats["by_type"]["bug"] >= 1
assert "open" in stats["by_status"]
assert stats["by_status"]["open"] >= 1
# 4. Update feedback status
update_result = await update_tool.run({
@@ -94,8 +94,8 @@ class TestCompleteWorkflow:
final_stats_result = await stats_tool.run({})
final_stats = final_stats_result.structured_content
assert "RESOLVED" in final_stats["by_status"]
assert final_stats["by_status"]["RESOLVED"] >= 1
assert "resolved" in final_stats["by_status"]
assert final_stats["by_status"]["resolved"] >= 1
# 8. Verify insights were recorded
assert len(insights.get_metrics("feedback_submitted")) >= 1
@@ -526,8 +526,8 @@ class TestPerformanceWorkflow:
stats_result = await stats_tool.run({})
stats = stats_result.structured_content
assert stats["total_count"] >= 100
assert "IMPROVEMENT" in stats["by_type"]
assert stats["by_type"]["IMPROVEMENT"] >= 100
assert "improvement" in stats["by_type"]
assert stats["by_type"]["improvement"] >= 100
# Insights should have recorded all submissions
submit_metrics = insights.get_metrics("feedback_submitted")
+523
View File
@@ -0,0 +1,523 @@
"""Statistics labels, event identity and logging, as fixed in 2026.10.01.4.
Statistics run on SQLite and, with FFB_TEST_DATABASE_URL set, on Postgres
through the feedback tools' own synchronous engine (psycopg2).
"""
import asyncio
import contextlib
import json
import os
import subprocess
import sys
import tomllib
from pathlib import Path
import pytest
from fastmcp import Client, FastMCP
from fastmcp.exceptions import ToolError
from packaging.requirements import Requirement
from sqlalchemy import String, select, text, type_coerce
from sqlalchemy.engine import make_url
import fastmcp_feedback
from fastmcp_feedback import (
FeedbackDatabase,
ManagementMixin,
RetrievalMixin,
SubmissionMixin,
SubmitFeedbackRequest,
get_database_stats,
)
from fastmcp_feedback.database import enum_label
from fastmcp_feedback.instrumentation import REDACTED, MemorySink, instrument
from fastmcp_feedback.models import Feedback, FeedbackStatus, FeedbackType
ROOT = Path(__file__).resolve().parents[2]
# Statistics
@pytest.fixture
async def feedback_db(db_backend, db_url):
"""A FeedbackDatabase on an empty database of the current backend."""
url = make_url(db_url("feedback"))
if db_backend == "postgres":
pytest.importorskip("psycopg2")
url = url.set(drivername="postgresql+psycopg2")
else:
url = url.set(drivername="sqlite")
db = FeedbackDatabase(url.render_as_string(hide_password=False))
await db.initialize()
yield db
await db.close()
async def seed(db):
"""Two bugs (one in progress) and a feature, through the tools."""
submit = SubmissionMixin(db)
ids = []
for kind in ("bug", "bug", "feature"):
result = await submit.submit_feedback(
SubmitFeedbackRequest(
type=kind, title=f"a {kind}", description="d", submitter="t"
)
)
assert result["success"], result
ids.append(result["feedback_id"])
updated = await ManagementMixin(db).update_feedback_status(
ids[0], FeedbackStatus.IN_PROGRESS
)
assert updated["success"], updated
return ids
class TestStatisticsLabels:
async def test_database_stores_enum_names(self, feedback_db):
# Why the mapping exists: the columns hold names on both backends.
await seed(feedback_db)
async with feedback_db.session() as session:
stored = session.execute(
select(type_coerce(Feedback.type, String)).distinct()
).scalars()
assert set(stored) == {"BUG", "FEATURE"}
async def test_statistics_keyed_by_value(self, feedback_db):
await seed(feedback_db)
stats = await RetrievalMixin(feedback_db).get_feedback_statistics()
assert "error" not in stats, stats
assert stats["total_count"] == 3
assert stats["by_type"] == {"bug": 2, "feature": 1}
assert stats["by_status"] == {"open": 2, "in_progress": 1}
async def test_statistics_keys_match_list_feedback(self, feedback_db):
await seed(feedback_db)
retrieval = RetrievalMixin(feedback_db)
stats = await retrieval.get_feedback_statistics()
listed = (await retrieval.list_feedback(per_page=100))["feedback"]
assert set(stats["by_type"]) == {f["type"] for f in listed}
assert set(stats["by_status"]) == {f["status"] for f in listed}
async def test_filter_by_statistics_key(self, feedback_db):
await seed(feedback_db)
retrieval = RetrievalMixin(feedback_db)
stats = await retrieval.get_feedback_statistics()
for kind, count in stats["by_type"].items():
listed = await retrieval.list_feedback(type_filter=kind)
assert listed["total_count"] == count
async def test_database_stats_keyed_by_value(self, feedback_db):
await seed(feedback_db)
stats = await get_database_stats(feedback_db)
assert stats["by_type"] == {"bug": 2, "feature": 1}
assert stats["by_status"] == {"open": 2, "in_progress": 1}
async def test_empty_database(self, feedback_db):
stats = await RetrievalMixin(feedback_db).get_feedback_statistics()
assert stats["by_type"] == {} and stats["by_status"] == {}
class TestEnumLabel:
@pytest.mark.parametrize(
("enum_cls", "stored", "label"),
[
(FeedbackType, FeedbackType.BUG, "bug"),
(FeedbackType, "BUG", "bug"),
(FeedbackType, "bug", "bug"),
(FeedbackStatus, "IN_PROGRESS", "in_progress"),
(FeedbackStatus, "in_progress", "in_progress"),
(FeedbackStatus, "archived", "archived"),
],
)
def test_label(self, enum_cls, stored, label):
assert enum_label(enum_cls, stored) == label
async def test_rows_written_as_values_are_merged(self, tmp_path):
# A row holding the value instead of the name (written by hand, or by
# another tool) is counted under the same key, not dropped.
db = FeedbackDatabase(f"sqlite:///{tmp_path}/legacy.db")
await db.initialize()
try:
await seed(db)
async with db.session() as session:
session.execute(
text(
"INSERT INTO feedback (type, title, description, submitter,"
" status, created_at, updated_at) VALUES ('bug', 't', 'd',"
" 's', 'open', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)"
)
)
session.commit()
stats = await RetrievalMixin(db).get_feedback_statistics()
assert stats["by_type"] == {"bug": 3, "feature": 1}
assert stats["by_status"] == {"open": 3, "in_progress": 1}
finally:
await db.close()
# Event identity
def make_app(resolver=None, **kwargs):
sink = MemorySink()
app = FastMCP("identity")
holder = {"spawned": []}
@app.tool
async def submit(job_id: str) -> dict:
holder["mw"].record_event("job.submitted", key=job_id)
return {"job_id": job_id}
@app.tool
def submit_sync(job_id: str) -> dict:
holder["mw"].record_event("job.submitted", key=job_id)
return {"job_id": job_id}
@app.tool
async def submit_as(job_id: str) -> dict:
holder["mw"].record_event(
"job.submitted",
key=job_id,
user_sub="given-user",
caller_kind="given-kind",
client_id="given-client",
)
return {"job_id": job_id}
@app.tool
async def chat() -> str:
holder["mw"].record_llm_call(model="m", provider="p")
return "ok"
@app.tool
async def fails() -> str:
holder["mw"].record_event("job.failed")
raise RuntimeError("no")
@app.tool
async def spawn(job_id: str) -> dict:
"""Start a task that records after this call has returned."""
started = asyncio.Event()
async def watcher():
started.set()
await asyncio.sleep(0.05)
holder["mw"].record_event("job.started", key=job_id)
holder["spawned"].append(asyncio.get_running_loop().create_task(watcher()))
await started.wait()
return {"job_id": job_id}
mw = instrument(app, [sink], identity_resolver=resolver, **kwargs)
holder["mw"] = mw
return app, sink, mw, holder["spawned"]
async def call(app, mw, *calls):
async with Client(app) as client:
for tool, args in calls:
with contextlib.suppress(ToolError):
await client.call_tool(tool, args)
await mw.flush()
class Resolver:
"""Counts its invocations; returns a different user each time."""
def __init__(self, extra=None):
self.calls = 0
self.extra = extra or {}
def __call__(self, context):
self.calls += 1
return {
"user_sub": f"user-{self.calls}",
"caller_kind": "pat",
"client_id": "addon",
"org": "acme",
**self.extra,
}
class AsyncResolver(Resolver):
async def __call__(self, context):
await asyncio.sleep(0)
return Resolver.__call__(self, context)
IDENTITY = ("user-1", "pat", "addon")
def ident(record):
return (record.user_sub, record.caller_kind, record.client_id)
class TestEventIdentity:
@pytest.mark.parametrize("resolver_cls", [Resolver, AsyncResolver])
async def test_event_in_call_gets_resolver_identity(self, resolver_cls):
who = resolver_cls()
app, sink, mw, _ = make_app(who)
await call(app, mw, ("submit", {"job_id": "j1"}))
[rec] = sink.calls
[ev] = sink.events
assert ident(ev) == IDENTITY
assert ident(rec) == IDENTITY
assert ev.call_id == rec.id
assert rec.identity == {"org": "acme"}
async def test_sync_tool_event_gets_identity(self):
app, sink, mw, _ = make_app(Resolver())
await call(app, mw, ("submit_sync", {"job_id": "j1"}))
await asyncio.sleep(0.01)
await mw.flush()
[ev] = sink.events
assert ident(ev) == IDENTITY
async def test_llm_call_event_gets_identity(self):
app, sink, mw, _ = make_app(Resolver())
await call(app, mw, ("chat", {}))
[ev] = sink.events
assert ev.kind == "llm.call" and ident(ev) == IDENTITY
async def test_failing_call_event_and_record_share_identity(self):
app, sink, mw, _ = make_app(Resolver())
await call(app, mw, ("fails", {}))
[rec] = sink.calls
[ev] = sink.events
assert rec.outcome == "error"
assert ident(ev) == ident(rec) == IDENTITY
async def test_resolver_runs_once_per_call(self):
who = Resolver()
app, sink, mw, _ = make_app(who)
await call(
app,
mw,
("submit", {"job_id": "a"}),
("chat", {}),
("fails", {}),
("submit", {"job_id": "b"}),
)
assert who.calls == 4
assert [r.user_sub for r in sink.calls] == [
"user-1",
"user-2",
"user-3",
"user-4",
]
# Each event carries its own call's identity.
assert [(e.user_sub, e.call_id) for e in sink.events] == [
(r.user_sub, r.id) for r in sink.calls
]
async def test_explicit_identity_wins(self):
app, sink, mw, _ = make_app(Resolver())
await call(app, mw, ("submit_as", {"job_id": "j1"}))
[ev] = sink.events
assert ident(ev) == ("given-user", "given-kind", "given-client")
assert ident(sink.calls[0]) == IDENTITY
async def test_task_after_call_gets_no_identity(self):
who = Resolver()
app, sink, mw, spawned = make_app(who)
await call(app, mw, ("spawn", {"job_id": "j1"}))
await asyncio.gather(*spawned)
await mw.flush()
[rec] = sink.calls
[ev] = sink.events
assert ident(rec) == IDENTITY
assert ev.kind == "job.started"
assert ev.call_id is None and ev.session_id is None
assert ident(ev) == (None, None, None)
assert who.calls == 1
async def test_outside_call_unchanged(self):
who = Resolver()
app, sink, mw, _ = make_app(who)
mw.record_event("job.finished", key="j1")
mw.record_event("job.finished", key="j2", user_sub="u9", client_id="c9")
await mw.flush()
first, second = sink.events
assert ident(first) == (None, None, None) and first.call_id is None
assert ident(second) == ("u9", None, "c9")
assert who.calls == 0
async def test_identity_redacted_for_events(self):
token = "bmcp_abcdefgh12345678"
app, sink, mw, _ = make_app(Resolver({"caller_kind": f"pat:{token}"}))
await call(app, mw, ("submit", {"job_id": "j1"}))
[ev] = sink.events
assert ev.caller_kind == f"pat:{REDACTED}"
assert sink.calls[0].caller_kind == f"pat:{REDACTED}"
async def test_failing_resolver_leaves_identity_empty(self):
calls = []
def boom(context):
calls.append(1)
raise RuntimeError("resolver bug")
app, sink, mw, _ = make_app(boom)
await call(app, mw, ("submit", {"job_id": "j1"}))
[rec] = sink.calls
[ev] = sink.events
assert rec.ok and ev.call_id == rec.id
assert ev.user_sub is None and rec.user_sub is None
assert calls == [1]
async def test_slow_resolver_cut_off_before_tool(self):
async def slow(context):
await asyncio.sleep(5)
return {"user_sub": "late"}
app, sink, mw, _ = make_app(slow, hook_timeout=0.05)
await call(app, mw, ("submit", {"job_id": "j1"}))
[rec] = sink.calls
[ev] = sink.events
assert rec.user_sub is None and ev.user_sub is None and rec.ok
async def test_mode_off_runs_no_resolver(self):
who = Resolver()
app, sink, mw, _ = make_app(who, mode="off")
await call(app, mw, ("submit", {"job_id": "j1"}))
assert who.calls == 0 and sink.calls == [] and sink.events == []
async def test_excluded_tool_runs_no_resolver(self):
who = Resolver()
app, sink, mw, _ = make_app(who, exclude_tools={"submit"})
await call(app, mw, ("submit", {"job_id": "j1"}))
[ev] = sink.events
assert who.calls == 0 and ev.user_sub is None and ev.call_id is None
# Logging
def run_python(code, *args, env=None):
return subprocess.run(
[sys.executable, "-c", code, *args],
capture_output=True,
text=True,
timeout=60,
env=env,
)
class TestLogging:
def test_import_configures_nothing_and_prints_nothing(self):
proc = run_python(
# FastMCP 2.x and 3.0 reset warning filters on import and authlib
# then warns; drop warnings so stderr holds only logging output.
"import warnings\nwarnings.showwarning = lambda *a, **k: None\n"
"import logging, fastmcp_feedback, fastmcp_feedback.instrumentation\n"
"root = logging.getLogger()\n"
"print(len(root.handlers), root.level)\n"
"print([type(h).__name__ for h in"
" logging.getLogger('fastmcp_feedback').handlers])\n"
)
assert proc.returncode == 0, proc.stderr
assert proc.stderr == ""
handlers, names = proc.stdout.splitlines()
assert handlers == f"0 {30}" # no handler, level still WARNING
assert names == "['NullHandler']"
def test_import_logs_nothing_above_debug(self):
# A host that set up logging first sees only the DEBUG line.
proc = run_python(
"import logging, json\n"
"seen = []\n"
"class H(logging.Handler):\n"
" def emit(self, r):\n"
" if r.name.startswith('fastmcp_feedback'):\n"
" seen.append((r.levelname, r.getMessage()))\n"
"logging.basicConfig(level=logging.DEBUG, handlers=[H()])\n"
"import fastmcp_feedback\n"
"print(json.dumps(seen))\n"
)
assert proc.returncode == 0, proc.stderr
seen = json.loads(proc.stdout.splitlines()[-1])
assert [level for level, _ in seen] == ["DEBUG"]
assert "initialized" in seen[0][1]
def test_setup_logging_is_opt_in(self):
proc = run_python("import fastmcp_feedback\nfastmcp_feedback.setup_logging()\n")
assert proc.returncode == 0
assert "fastmcp_feedback - INFO - FastMCP Feedback v" in proc.stderr
def test_package_info_matches_pyproject(self):
pyproject = ROOT / "pyproject.toml"
if not pyproject.exists():
pytest.skip("not running from a source checkout")
project = tomllib.loads(pyproject.read_text())["project"]
info = fastmcp_feedback.PACKAGE_INFO
assert info["version"] == fastmcp_feedback.__version__ == project["version"]
def reqs(items):
return {str(Requirement(r)) for r in items}
assert reqs(info["dependencies"]) == reqs(project["dependencies"])
extras = project["optional-dependencies"]
assert set(info["optional_dependencies"]) == set(extras)
for name, items in extras.items():
if any(r.startswith("fastmcp-feedback") for r in items):
continue # self-references are expanded in the metadata
assert reqs(info["optional_dependencies"][name]) == reqs(items), name
assert info["classifiers"] == sorted(project["classifiers"])
class TestBackfillCliLogging:
@pytest.fixture
def stored_db(self, tmp_path):
"""A calls database with one error, written by the CLI's own sink."""
url = f"sqlite+aiosqlite:///{tmp_path}/calls.db"
code = (
"import asyncio, sys\n"
"from datetime import UTC, datetime\n"
"from fastmcp_feedback.instrumentation import DatabaseSink,"
" ToolCallRecord\n"
"async def main():\n"
" sink = DatabaseSink(sys.argv[1], create_tables=True)\n"
" await sink.write([ToolCallRecord(tool='t',"
" started_at=datetime.now(UTC), duration_ms=1, ok=False,"
" outcome='error', mode='meta', error_type='E',"
" error_message='broke')])\n"
" await sink.ensure_embeddings()\n"
" await sink.engine.dispose()\n"
"asyncio.run(main())\n"
)
proc = run_python(code, url)
assert proc.returncode == 0, proc.stderr
return url
def backfill(self, url, *flags):
env = {k: v for k, v in os.environ.items() if k != "FFB_EMBED_API_KEY"}
return subprocess.run(
[
sys.executable,
"-m",
"fastmcp_feedback.instrumentation.backfill",
"--database-url",
url,
"--dry-run",
*flags,
],
capture_output=True,
text=True,
env=env,
timeout=60,
)
def test_verbose_logs_at_info(self, stored_db):
proc = self.backfill(stored_db, "-v")
assert proc.returncode == 0, proc.stderr
assert "INFO fastmcp_feedback.instrumentation.backfill: Backfill dry run" in (
proc.stderr
)
assert json.loads(proc.stdout.splitlines()[-1])["call_error"] == 1
def test_quiet_without_verbose(self, stored_db):
proc = self.backfill(stored_db)
assert proc.returncode == 0, proc.stderr
assert "INFO" not in proc.stderr
assert "initialized" not in proc.stderr
+5 -5
View File
@@ -140,10 +140,10 @@ class TestDatabaseAdvancedOperations:
stats = await get_database_stats(db)
assert stats["total_count"] == 2
assert stats["by_type"]["BUG"] == 1
assert stats["by_type"]["FEATURE"] == 1
assert stats["by_status"]["OPEN"] == 1
assert stats["by_status"]["RESOLVED"] == 1
assert stats["by_type"]["bug"] == 1
assert stats["by_type"]["feature"] == 1
assert stats["by_status"]["open"] == 1
assert stats["by_status"]["resolved"] == 1
assert stats["database_url"] == db.database_url
assert stats["is_initialized"] is True
@@ -629,7 +629,7 @@ class TestDatabasePerformanceAndLimits:
stats = await get_database_stats(db)
assert stats["total_count"] == 1000
assert len(stats["by_type"]) >= 1
assert stats["by_type"]["IMPROVEMENT"] == 1000
assert stats["by_type"]["improvement"] == 1000
await db.close()
+1 -1
View File
@@ -509,7 +509,7 @@ class TestMixinIntegration:
# 5. Get statistics
stats = await retrieval.get_feedback_statistics()
assert stats["total_count"] >= 1
assert "IN_PROGRESS" in stats["by_status"]
assert "in_progress" in stats["by_status"]
# 6. Final status update
final_result = await management.update_feedback_status(
+5 -2
View File
@@ -155,7 +155,10 @@ class TestSampling:
# A sampled-out call still returns its result.
assert results[0].structured_content == {"status": "idle"}
async def test_sampled_out_ok_calls_skip_hooks(self):
async def test_sampled_out_ok_calls_skip_enricher(self):
# The identity resolver runs before the tool, when sampling cannot
# know yet whether the call will fail or record events; the enricher
# runs after, and only for calls that are kept.
seen = []
def who(context):
@@ -174,7 +177,7 @@ class TestSampling:
)
await call(app, mw, ("poll", {}))
assert sink.records == []
assert seen == []
assert seen == ["identity"]
async def test_unsampled_tools_recorded_without_rate_or_roll(self):
rolls = Rolls()
Generated
+62 -47
View File
@@ -601,7 +601,7 @@ wheels = [
[[package]]
name = "fastmcp-feedback"
version = "2026.10.1.3"
version = "2026.10.1.4"
source = { editable = "." }
dependencies = [
{ name = "fastmcp" },
@@ -630,6 +630,7 @@ dev = [
{ name = "asyncpg" },
{ name = "mypy" },
{ name = "pgvector" },
{ name = "psycopg2-binary" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-cov" },
@@ -655,6 +656,7 @@ test = [
{ name = "aiosqlite" },
{ name = "asyncpg" },
{ name = "pgvector" },
{ name = "psycopg2-binary" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-cov" },
@@ -676,7 +678,9 @@ requires-dist = [
{ name = "pgvector", marker = "extra == 'dev'", specifier = ">=0.5.0" },
{ name = "pgvector", marker = "extra == 'embeddings'", specifier = ">=0.5.0" },
{ name = "pgvector", marker = "extra == 'test'", specifier = ">=0.5.0" },
{ name = "psycopg2-binary", marker = "extra == 'dev'", specifier = ">=2.9.0" },
{ name = "psycopg2-binary", marker = "extra == 'postgresql'", specifier = ">=2.9.0" },
{ name = "psycopg2-binary", marker = "extra == 'test'", specifier = ">=2.9.0" },
{ name = "pydantic", specifier = ">=2.11.7" },
{ name = "pymysql", marker = "extra == 'mysql'", specifier = ">=1.1.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.2.0" },
@@ -1355,54 +1359,65 @@ wheels = [
[[package]]
name = "psycopg2-binary"
version = "2.9.11"
version = "2.9.13"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ac/6c/8767aaa597ba424643dc87348c6f1754dd9f48e80fdc1b9f7ca5c3a7c213/psycopg2-binary-2.9.11.tar.gz", hash = "sha256:b6aed9e096bf63f9e75edf2581aa9a7e7186d97ab5c177aa6c87797cd591236c", size = 379620, upload-time = "2025-10-10T11:14:48.041Z" }
sdist = { url = "https://files.pythonhosted.org/packages/ed/76/7b4383014be0fcc6c1c0e24292845a14e1672cf17fca62ca0a2bd5f4563d/psycopg2_binary-2.9.13.tar.gz", hash = "sha256:e324ecf60f952d21dd11413b8bbed0951bbd99579a06fd06f28bfc37737cd373", size = 378112, upload-time = "2026-09-10T00:06:12.199Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/ae/8d8266f6dd183ab4d48b95b9674034e1b482a3f8619b33a0d86438694577/psycopg2_binary-2.9.11-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0e8480afd62362d0a6a27dd09e4ca2def6fa50ed3a4e7c09165266106b2ffa10", size = 3756452, upload-time = "2025-10-10T11:11:11.583Z" },
{ url = "https://files.pythonhosted.org/packages/4b/34/aa03d327739c1be70e09d01182619aca8ebab5970cd0cfa50dd8b9cec2ac/psycopg2_binary-2.9.11-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:763c93ef1df3da6d1a90f86ea7f3f806dc06b21c198fa87c3c25504abec9404a", size = 3863957, upload-time = "2025-10-10T11:11:16.932Z" },
{ url = "https://files.pythonhosted.org/packages/48/89/3fdb5902bdab8868bbedc1c6e6023a4e08112ceac5db97fc2012060e0c9a/psycopg2_binary-2.9.11-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e164359396576a3cc701ba8af4751ae68a07235d7a380c631184a611220d9a4", size = 4410955, upload-time = "2025-10-10T11:11:21.21Z" },
{ url = "https://files.pythonhosted.org/packages/ce/24/e18339c407a13c72b336e0d9013fbbbde77b6fd13e853979019a1269519c/psycopg2_binary-2.9.11-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:d57c9c387660b8893093459738b6abddbb30a7eab058b77b0d0d1c7d521ddfd7", size = 4468007, upload-time = "2025-10-10T11:11:24.831Z" },
{ url = "https://files.pythonhosted.org/packages/91/7e/b8441e831a0f16c159b5381698f9f7f7ed54b77d57bc9c5f99144cc78232/psycopg2_binary-2.9.11-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2c226ef95eb2250974bf6fa7a842082b31f68385c4f3268370e3f3870e7859ee", size = 4165012, upload-time = "2025-10-10T11:11:29.51Z" },
{ url = "https://files.pythonhosted.org/packages/0d/61/4aa89eeb6d751f05178a13da95516c036e27468c5d4d2509bb1e15341c81/psycopg2_binary-2.9.11-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a311f1edc9967723d3511ea7d2708e2c3592e3405677bf53d5c7246753591fbb", size = 3981881, upload-time = "2025-10-30T02:55:07.332Z" },
{ url = "https://files.pythonhosted.org/packages/76/a1/2f5841cae4c635a9459fe7aca8ed771336e9383b6429e05c01267b0774cf/psycopg2_binary-2.9.11-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ebb415404821b6d1c47353ebe9c8645967a5235e6d88f914147e7fd411419e6f", size = 3650985, upload-time = "2025-10-10T11:11:34.975Z" },
{ url = "https://files.pythonhosted.org/packages/84/74/4defcac9d002bca5709951b975173c8c2fa968e1a95dc713f61b3a8d3b6a/psycopg2_binary-2.9.11-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f07c9c4a5093258a03b28fab9b4f151aa376989e7f35f855088234e656ee6a94", size = 3296039, upload-time = "2025-10-10T11:11:40.432Z" },
{ url = "https://files.pythonhosted.org/packages/6d/c2/782a3c64403d8ce35b5c50e1b684412cf94f171dc18111be8c976abd2de1/psycopg2_binary-2.9.11-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:00ce1830d971f43b667abe4a56e42c1e2d594b32da4802e44a73bacacb25535f", size = 3043477, upload-time = "2025-10-30T02:55:11.182Z" },
{ url = "https://files.pythonhosted.org/packages/c8/31/36a1d8e702aa35c38fc117c2b8be3f182613faa25d794b8aeaab948d4c03/psycopg2_binary-2.9.11-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:cffe9d7697ae7456649617e8bb8d7a45afb71cd13f7ab22af3e5c61f04840908", size = 3345842, upload-time = "2025-10-10T11:11:45.366Z" },
{ url = "https://files.pythonhosted.org/packages/6e/b4/a5375cda5b54cb95ee9b836930fea30ae5a8f14aa97da7821722323d979b/psycopg2_binary-2.9.11-cp311-cp311-win_amd64.whl", hash = "sha256:304fd7b7f97eef30e91b8f7e720b3db75fee010b520e434ea35ed1ff22501d03", size = 2713894, upload-time = "2025-10-10T11:11:48.775Z" },
{ url = "https://files.pythonhosted.org/packages/d8/91/f870a02f51be4a65987b45a7de4c2e1897dd0d01051e2b559a38fa634e3e/psycopg2_binary-2.9.11-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:be9b840ac0525a283a96b556616f5b4820e0526addb8dcf6525a0fa162730be4", size = 3756603, upload-time = "2025-10-10T11:11:52.213Z" },
{ url = "https://files.pythonhosted.org/packages/27/fa/cae40e06849b6c9a95eb5c04d419942f00d9eaac8d81626107461e268821/psycopg2_binary-2.9.11-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f090b7ddd13ca842ebfe301cd587a76a4cf0913b1e429eb92c1be5dbeb1a19bc", size = 3864509, upload-time = "2025-10-10T11:11:56.452Z" },
{ url = "https://files.pythonhosted.org/packages/2d/75/364847b879eb630b3ac8293798e380e441a957c53657995053c5ec39a316/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ab8905b5dcb05bf3fb22e0cf90e10f469563486ffb6a96569e51f897c750a76a", size = 4411159, upload-time = "2025-10-10T11:12:00.49Z" },
{ url = "https://files.pythonhosted.org/packages/6f/a0/567f7ea38b6e1c62aafd58375665a547c00c608a471620c0edc364733e13/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf940cd7e7fec19181fdbc29d76911741153d51cab52e5c21165f3262125685e", size = 4468234, upload-time = "2025-10-10T11:12:04.892Z" },
{ url = "https://files.pythonhosted.org/packages/30/da/4e42788fb811bbbfd7b7f045570c062f49e350e1d1f3df056c3fb5763353/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fa0f693d3c68ae925966f0b14b8edda71696608039f4ed61b1fe9ffa468d16db", size = 4166236, upload-time = "2025-10-10T11:12:11.674Z" },
{ url = "https://files.pythonhosted.org/packages/3c/94/c1777c355bc560992af848d98216148be5f1be001af06e06fc49cbded578/psycopg2_binary-2.9.11-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a1cf393f1cdaf6a9b57c0a719a1068ba1069f022a59b8b1fe44b006745b59757", size = 3983083, upload-time = "2025-10-30T02:55:15.73Z" },
{ url = "https://files.pythonhosted.org/packages/bd/42/c9a21edf0e3daa7825ed04a4a8588686c6c14904344344a039556d78aa58/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7a6beb4beaa62f88592ccc65df20328029d721db309cb3250b0aae0fa146c3", size = 3652281, upload-time = "2025-10-10T11:12:17.713Z" },
{ url = "https://files.pythonhosted.org/packages/12/22/dedfbcfa97917982301496b6b5e5e6c5531d1f35dd2b488b08d1ebc52482/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:31b32c457a6025e74d233957cc9736742ac5a6cb196c6b68499f6bb51390bd6a", size = 3298010, upload-time = "2025-10-10T11:12:22.671Z" },
{ url = "https://files.pythonhosted.org/packages/66/ea/d3390e6696276078bd01b2ece417deac954dfdd552d2edc3d03204416c0c/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:edcb3aeb11cb4bf13a2af3c53a15b3d612edeb6409047ea0b5d6a21a9d744b34", size = 3044641, upload-time = "2025-10-30T02:55:19.929Z" },
{ url = "https://files.pythonhosted.org/packages/12/9a/0402ded6cbd321da0c0ba7d34dc12b29b14f5764c2fc10750daa38e825fc/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:62b6d93d7c0b61a1dd6197d208ab613eb7dcfdcca0a49c42ceb082257991de9d", size = 3347940, upload-time = "2025-10-10T11:12:26.529Z" },
{ url = "https://files.pythonhosted.org/packages/b1/d2/99b55e85832ccde77b211738ff3925a5d73ad183c0b37bcbbe5a8ff04978/psycopg2_binary-2.9.11-cp312-cp312-win_amd64.whl", hash = "sha256:b33fabeb1fde21180479b2d4667e994de7bbf0eec22832ba5d9b5e4cf65b6c6d", size = 2714147, upload-time = "2025-10-10T11:12:29.535Z" },
{ url = "https://files.pythonhosted.org/packages/ff/a8/a2709681b3ac11b0b1786def10006b8995125ba268c9a54bea6f5ae8bd3e/psycopg2_binary-2.9.11-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b8fb3db325435d34235b044b199e56cdf9ff41223a4b9752e8576465170bb38c", size = 3756572, upload-time = "2025-10-10T11:12:32.873Z" },
{ url = "https://files.pythonhosted.org/packages/62/e1/c2b38d256d0dafd32713e9f31982a5b028f4a3651f446be70785f484f472/psycopg2_binary-2.9.11-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:366df99e710a2acd90efed3764bb1e28df6c675d33a7fb40df9b7281694432ee", size = 3864529, upload-time = "2025-10-10T11:12:36.791Z" },
{ url = "https://files.pythonhosted.org/packages/11/32/b2ffe8f3853c181e88f0a157c5fb4e383102238d73c52ac6d93a5c8bffe6/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c55b385daa2f92cb64b12ec4536c66954ac53654c7f15a203578da4e78105c0", size = 4411242, upload-time = "2025-10-10T11:12:42.388Z" },
{ url = "https://files.pythonhosted.org/packages/10/04/6ca7477e6160ae258dc96f67c371157776564679aefd247b66f4661501a2/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c0377174bf1dd416993d16edc15357f6eb17ac998244cca19bc67cdc0e2e5766", size = 4468258, upload-time = "2025-10-10T11:12:48.654Z" },
{ url = "https://files.pythonhosted.org/packages/3c/7e/6a1a38f86412df101435809f225d57c1a021307dd0689f7a5e7fe83588b1/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5c6ff3335ce08c75afaed19e08699e8aacf95d4a260b495a4a8545244fe2ceb3", size = 4166295, upload-time = "2025-10-10T11:12:52.525Z" },
{ url = "https://files.pythonhosted.org/packages/f2/7d/c07374c501b45f3579a9eb761cbf2604ddef3d96ad48679112c2c5aa9c25/psycopg2_binary-2.9.11-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:84011ba3109e06ac412f95399b704d3d6950e386b7994475b231cf61eec2fc1f", size = 3983133, upload-time = "2025-10-30T02:55:24.329Z" },
{ url = "https://files.pythonhosted.org/packages/82/56/993b7104cb8345ad7d4516538ccf8f0d0ac640b1ebd8c754a7b024e76878/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ba34475ceb08cccbdd98f6b46916917ae6eeb92b5ae111df10b544c3a4621dc4", size = 3652383, upload-time = "2025-10-10T11:12:56.387Z" },
{ url = "https://files.pythonhosted.org/packages/2d/ac/eaeb6029362fd8d454a27374d84c6866c82c33bfc24587b4face5a8e43ef/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b31e90fdd0f968c2de3b26ab014314fe814225b6c324f770952f7d38abf17e3c", size = 3298168, upload-time = "2025-10-10T11:13:00.403Z" },
{ url = "https://files.pythonhosted.org/packages/2b/39/50c3facc66bded9ada5cbc0de867499a703dc6bca6be03070b4e3b65da6c/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d526864e0f67f74937a8fce859bd56c979f5e2ec57ca7c627f5f1071ef7fee60", size = 3044712, upload-time = "2025-10-30T02:55:27.975Z" },
{ url = "https://files.pythonhosted.org/packages/9c/8e/b7de019a1f562f72ada81081a12823d3c1590bedc48d7d2559410a2763fe/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04195548662fa544626c8ea0f06561eb6203f1984ba5b4562764fbeb4c3d14b1", size = 3347549, upload-time = "2025-10-10T11:13:03.971Z" },
{ url = "https://files.pythonhosted.org/packages/80/2d/1bb683f64737bbb1f86c82b7359db1eb2be4e2c0c13b947f80efefa7d3e5/psycopg2_binary-2.9.11-cp313-cp313-win_amd64.whl", hash = "sha256:efff12b432179443f54e230fdf60de1f6cc726b6c832db8701227d089310e8aa", size = 2714215, upload-time = "2025-10-10T11:13:07.14Z" },
{ url = "https://files.pythonhosted.org/packages/64/12/93ef0098590cf51d9732b4f139533732565704f45bdc1ffa741b7c95fb54/psycopg2_binary-2.9.11-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:92e3b669236327083a2e33ccfa0d320dd01b9803b3e14dd986a4fc54aa00f4e1", size = 3756567, upload-time = "2025-10-10T11:13:11.885Z" },
{ url = "https://files.pythonhosted.org/packages/7c/a9/9d55c614a891288f15ca4b5209b09f0f01e3124056924e17b81b9fa054cc/psycopg2_binary-2.9.11-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e0deeb03da539fa3577fcb0b3f2554a97f7e5477c246098dbb18091a4a01c16f", size = 3864755, upload-time = "2025-10-10T11:13:17.727Z" },
{ url = "https://files.pythonhosted.org/packages/13/1e/98874ce72fd29cbde93209977b196a2edae03f8490d1bd8158e7f1daf3a0/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9b52a3f9bb540a3e4ec0f6ba6d31339727b2950c9772850d6545b7eae0b9d7c5", size = 4411646, upload-time = "2025-10-10T11:13:24.432Z" },
{ url = "https://files.pythonhosted.org/packages/5a/bd/a335ce6645334fb8d758cc358810defca14a1d19ffbc8a10bd38a2328565/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:db4fd476874ccfdbb630a54426964959e58da4c61c9feba73e6094d51303d7d8", size = 4468701, upload-time = "2025-10-10T11:13:29.266Z" },
{ url = "https://files.pythonhosted.org/packages/44/d6/c8b4f53f34e295e45709b7568bf9b9407a612ea30387d35eb9fa84f269b4/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:47f212c1d3be608a12937cc131bd85502954398aaa1320cb4c14421a0ffccf4c", size = 4166293, upload-time = "2025-10-10T11:13:33.336Z" },
{ url = "https://files.pythonhosted.org/packages/4b/e0/f8cc36eadd1b716ab36bb290618a3292e009867e5c97ce4aba908cb99644/psycopg2_binary-2.9.11-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e35b7abae2b0adab776add56111df1735ccc71406e56203515e228a8dc07089f", size = 3983184, upload-time = "2025-10-30T02:55:32.483Z" },
{ url = "https://files.pythonhosted.org/packages/53/3e/2a8fe18a4e61cfb3417da67b6318e12691772c0696d79434184a511906dc/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fcf21be3ce5f5659daefd2b3b3b6e4727b028221ddc94e6c1523425579664747", size = 3652650, upload-time = "2025-10-10T11:13:38.181Z" },
{ url = "https://files.pythonhosted.org/packages/76/36/03801461b31b29fe58d228c24388f999fe814dfc302856e0d17f97d7c54d/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9bd81e64e8de111237737b29d68039b9c813bdf520156af36d26819c9a979e5f", size = 3298663, upload-time = "2025-10-10T11:13:44.878Z" },
{ url = "https://files.pythonhosted.org/packages/97/77/21b0ea2e1a73aa5fa9222b2a6b8ba325c43c3a8d54272839c991f2345656/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:32770a4d666fbdafab017086655bcddab791d7cb260a16679cc5a7338b64343b", size = 3044737, upload-time = "2025-10-30T02:55:35.69Z" },
{ url = "https://files.pythonhosted.org/packages/67/69/f36abe5f118c1dca6d3726ceae164b9356985805480731ac6712a63f24f0/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3cb3a676873d7506825221045bd70e0427c905b9c8ee8d6acd70cfcbd6e576d", size = 3347643, upload-time = "2025-10-10T11:13:53.499Z" },
{ url = "https://files.pythonhosted.org/packages/e1/36/9c0c326fe3a4227953dfb29f5d0c8ae3b8eb8c1cd2967aa569f50cb3c61f/psycopg2_binary-2.9.11-cp314-cp314-win_amd64.whl", hash = "sha256:4012c9c954dfaccd28f94e84ab9f94e12df76b4afb22331b1f0d3154893a6316", size = 2803913, upload-time = "2025-10-10T11:13:57.058Z" },
{ url = "https://files.pythonhosted.org/packages/7b/03/639c96ff8ffb933868252308a9917ff4170d8c7f4bd16cd0ea2536814126/psycopg2_binary-2.9.13-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d19aec88857d2a52f99eefcefdbbb45921fb2f777bee5186a355a23d9cf8a0b9", size = 3725651, upload-time = "2026-09-09T23:54:29.277Z" },
{ url = "https://files.pythonhosted.org/packages/53/5e/d50eb688e7e6dfd1499e68cf2d02f44a348b88f381cbc2bebeed15e345f4/psycopg2_binary-2.9.13-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:32cd049095135d2b69e824aea9056745a4aaaa9115a9febbc65584793665d0d0", size = 3817384, upload-time = "2026-09-09T23:54:31.118Z" },
{ url = "https://files.pythonhosted.org/packages/6c/f3/4004cfbbfc52b9b13ffd499f2103eb05246e92828c7237d2c198e028b95c/psycopg2_binary-2.9.13-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6e696297891b56ff0115f0665de6ad774e1e301e4f60745b8d5024001ae7c2f6", size = 4585497, upload-time = "2026-09-09T23:54:33.169Z" },
{ url = "https://files.pythonhosted.org/packages/97/63/057c65532bd12cdf9d4f568e59c2a078a38e9ba8f7f251292968dc781905/psycopg2_binary-2.9.13-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:930e7e58b33a4f9c39e7532d7a40147925cf3372baed4229cbebe0cf3ba9ce6b", size = 4281172, upload-time = "2026-09-09T23:54:35.747Z" },
{ url = "https://files.pythonhosted.org/packages/43/4b/9fd928eaea9ec1e8d74fed83c9e82826f830506ba0d8c58a8fd41ca93656/psycopg2_binary-2.9.13-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3aea95340825f5ff236e7b40f0b5602c2c77a1e95943f71fae34909834043d29", size = 5900973, upload-time = "2026-09-09T23:54:37.866Z" },
{ url = "https://files.pythonhosted.org/packages/f8/2b/59e1519a22622169e2244f12227b3acde6114ea531a349292f455ab8503f/psycopg2_binary-2.9.13-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:27e539b4cafd5e03dcd32921db1b12dd72fe549dd06bae6d4d2a5b5838465f24", size = 4118611, upload-time = "2026-09-09T23:54:39.739Z" },
{ url = "https://files.pythonhosted.org/packages/87/c7/c3d84e330d1584efa0560b756914f9128b1b6fa2aba93fde54bf101d4f65/psycopg2_binary-2.9.13-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a6444ac48e2c04f691c2ddd542b38ba30c89463a2d446b3d74ec7d8fc90c964", size = 3659643, upload-time = "2026-09-09T23:54:41.882Z" },
{ url = "https://files.pythonhosted.org/packages/af/fc/317d248503aa29a5051ee49a7253daffe1c57e43ba174020e00bbd87c479/psycopg2_binary-2.9.13-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:8cb734989420c18ca1b71a82da880e11988f5ff3fcdaadd669161de3e98794ac", size = 3305145, upload-time = "2026-09-09T23:54:43.818Z" },
{ url = "https://files.pythonhosted.org/packages/9d/d2/8b23c57591c6d29a463748ffc634401ce14719dba7e78ab91bcbeac70934/psycopg2_binary-2.9.13-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:f47f23db2d70db39cfb714b64fd5df76595b51b2ec0a669710a78f2dceb0c3f8", size = 3051614, upload-time = "2026-09-09T23:54:45.548Z" },
{ url = "https://files.pythonhosted.org/packages/4f/f2/b10a046cc19ab1e01b92226eeb6e8653be7e6691939991b2f347995831b1/psycopg2_binary-2.9.13-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f28b5f2fa8154d0d97e97a664136f58d1639ca008d45d6e09e69fff24826abee", size = 3353133, upload-time = "2026-09-09T23:54:47.285Z" },
{ url = "https://files.pythonhosted.org/packages/40/2c/dd379facaa4bd41d7b04711ff30931ca62981c00a91502250ffb49da08a7/psycopg2_binary-2.9.13-cp311-cp311-win_amd64.whl", hash = "sha256:70d091f5c3a6177fac50c0da20181ce0e0c053f1e43c872d5f75bd6d9429c020", size = 2771441, upload-time = "2026-09-09T23:54:49.016Z" },
{ url = "https://files.pythonhosted.org/packages/fb/d1/d0125c56b865e3bc9f318d84930b2df71a729229dbb0ce12de748a82a6d7/psycopg2_binary-2.9.13-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2bf9f97a6df69a5d89d054b8cf5257a0916096c479800715fbfe7974dbcb3a26", size = 3725735, upload-time = "2026-09-09T23:54:51.182Z" },
{ url = "https://files.pythonhosted.org/packages/54/a5/b5a73d0910555e38ee12c49c1740855f8a1e9776e87d65f0c51e1bab762a/psycopg2_binary-2.9.13-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:07b7bd9f410650c34c3532162cc329f112368d78a3fc8668cb1ea9df61bc11bf", size = 3818000, upload-time = "2026-09-09T23:54:53.229Z" },
{ url = "https://files.pythonhosted.org/packages/3d/43/3e4783f62ae3f4fc19a5acf8d1c394df54458f1336febe188f317556d2a7/psycopg2_binary-2.9.13-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:0463c00f946517f3e69192a59e6601e023ff9de45ad0a875eda3d6b1bebeb7ce", size = 4585760, upload-time = "2026-09-09T23:54:55.313Z" },
{ url = "https://files.pythonhosted.org/packages/8d/c4/a9a67ae65ad3d567eb0fc9cdf9a5a2783b779aecdcdc8945f1807b13d99e/psycopg2_binary-2.9.13-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e3861eba31f8ea8663fd876166b032fd89179e42aa63764d6feb281f13f9eb60", size = 4282380, upload-time = "2026-09-09T23:54:57.362Z" },
{ url = "https://files.pythonhosted.org/packages/b3/db/9d459d3da12e0b841cf1596579455aaa27e9e593e3e9a5a4ded5a55a7c15/psycopg2_binary-2.9.13-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3dc3372b3731b3ef23407fe06b94f640ef87a2bda242fa386033d5589c87514a", size = 5902039, upload-time = "2026-09-09T23:55:01.955Z" },
{ url = "https://files.pythonhosted.org/packages/d6/53/21079c10a581c50b6817498eda7c3481c1b3cdb41482bd08ebfccd3664c4/psycopg2_binary-2.9.13-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0405dd4d97720e7ab177aa02e493f524907c4cb3c445ac173e2627948d3d0528", size = 4119840, upload-time = "2026-09-09T23:55:04.336Z" },
{ url = "https://files.pythonhosted.org/packages/c4/ce/71e8d9e1b4f3e78157b49a5abdff50d915e95f2812550f6c9b4f2e4d5e94/psycopg2_binary-2.9.13-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b6ae51708201f501a171b02419d0c30878a743c369c9054eb1289f0f8d5979e2", size = 3661077, upload-time = "2026-09-09T23:55:06.118Z" },
{ url = "https://files.pythonhosted.org/packages/d9/54/b17616472f09a0fae96f8852692948b7eaa7c971d7629696da0e5932d996/psycopg2_binary-2.9.13-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:81682c227cc1849c4a6adf7b85274229073bb4c9d6ad5697222c695dcea5a8a7", size = 3307202, upload-time = "2026-09-09T23:55:08.061Z" },
{ url = "https://files.pythonhosted.org/packages/77/c7/d9737e222a377dac67a0ce0a2c73e7231a57f5cf18bb35a65d5c8d45d5d2/psycopg2_binary-2.9.13-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:13d955f6054a705a19554364fe9888d0a6e8b0746dc7ebc08a447c7b4fd4145c", size = 3052893, upload-time = "2026-09-09T23:55:10.209Z" },
{ url = "https://files.pythonhosted.org/packages/7d/3d/c406c9f698f518c264381192c2bdf8952ee84e469ffa9f82db1411f57385/psycopg2_binary-2.9.13-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7e2405196a8cfe6cd3e54172a54452dcf85c241eaf2e9dde7190d7469f7f5ef7", size = 3355210, upload-time = "2026-09-09T23:55:11.883Z" },
{ url = "https://files.pythonhosted.org/packages/27/64/6e3a96699770af2d0d49a2002f722c69b656fc27623ff89281cf2b109644/psycopg2_binary-2.9.13-cp312-cp312-win_amd64.whl", hash = "sha256:376ebf7d8aee4b7386b2bac31fdc27911e7e57cd0a88f1e038b8b149398ac008", size = 2767784, upload-time = "2026-09-09T23:55:13.823Z" },
{ url = "https://files.pythonhosted.org/packages/82/0a/795f2869788373cf7d08410341a444196e8ccebbac07a70a8f9a1f60e72f/psycopg2_binary-2.9.13-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4d66bfd44a46eb88cff0287929a4193fb45166b6c1f84bb1b233cc17ece0813c", size = 3725723, upload-time = "2026-09-09T23:55:15.887Z" },
{ url = "https://files.pythonhosted.org/packages/b5/63/5a9633f4563a73beba69b20a846ddd14c1c6ac072f5e8aab0da97ffabc2a/psycopg2_binary-2.9.13-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f818161d2302b3b3e9c75d5a1d0a5c5679e92e45cfec6432b9d5432dde5ff1f1", size = 3817976, upload-time = "2026-09-09T23:55:18.025Z" },
{ url = "https://files.pythonhosted.org/packages/6c/e2/b2e3b3a4331dc8b58e328cda30f3d0cc43a94b7aaf0c8383efd53dd10e95/psycopg2_binary-2.9.13-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:31db6cba66df5231dfd91d9f69188bec3fe6c8baae384e93a0ce792067ee2d98", size = 4585813, upload-time = "2026-09-09T23:55:20.112Z" },
{ url = "https://files.pythonhosted.org/packages/56/5c/87daea77c4132114d1a5da3a4928dd59446c3b3cc73d288cae08cf0b91a6/psycopg2_binary-2.9.13-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f04ada42bcd537adbaf8b7f3140237a204e452a88d0c1831cfce69f7d2e59f4e", size = 4282438, upload-time = "2026-09-09T23:55:22.329Z" },
{ url = "https://files.pythonhosted.org/packages/91/e5/56f9efdc9337acbd1a75798d97163183b63a1babc17602f7163009506c96/psycopg2_binary-2.9.13-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa37089795bd9701576edc2eb5849ce77a439eda9dfdfa47857449332cfa5292", size = 5902064, upload-time = "2026-09-09T23:55:24.37Z" },
{ url = "https://files.pythonhosted.org/packages/e4/15/f7ed0b90b47b73a9087306b42267eccfd919f92c0fb057e46bd2fa2efa4d/psycopg2_binary-2.9.13-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:41c2eb569ebd0e1b02d30d361a46932923b193fe1b5e641fb4d547c75e218955", size = 4119848, upload-time = "2026-09-09T23:55:26.433Z" },
{ url = "https://files.pythonhosted.org/packages/42/08/3091347b9fc5766e979aba6b0756ad14ce867a6bb245f3d69ac71fb768c6/psycopg2_binary-2.9.13-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3f699a5225094a5c61402984e2fc1eca20e940223e76767c88189efb0c313f69", size = 3661129, upload-time = "2026-09-09T23:55:28.449Z" },
{ url = "https://files.pythonhosted.org/packages/34/c4/4f9a84d55484c9794b364548eb6e1fe10a57f123afd19729e5a1cc8ad7fc/psycopg2_binary-2.9.13-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5f04ae99c9fbb94c3197ec88599ed7db921f6adcddfe83687a74c7ead4037c22", size = 3307262, upload-time = "2026-09-09T23:55:30.384Z" },
{ url = "https://files.pythonhosted.org/packages/83/42/6eba8306a61dc890805ae475a9e71790a1c5461ccacbd4f0a1f3f57b40f0/psycopg2_binary-2.9.13-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:81404c37e0344ebcf10aac127d33d35137e5dbab1daf9f3deee46188fd5879c2", size = 3052898, upload-time = "2026-09-09T23:55:32.961Z" },
{ url = "https://files.pythonhosted.org/packages/b3/5d/42a8935ab280e8dcd7c07a655c0c3d25d62e9e242be1961ac14630f1294a/psycopg2_binary-2.9.13-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:feb7b1856f6ca805cc0e08739858f6cdfed8ce903390126af30343c62899a389", size = 3355265, upload-time = "2026-09-09T23:55:35.071Z" },
{ url = "https://files.pythonhosted.org/packages/87/c2/0e0ffb4caeb651631cbc6c8ead83e2a16457750b1d2eb7f5ef111c1f4d36/psycopg2_binary-2.9.13-cp313-cp313-win_amd64.whl", hash = "sha256:691da68ae5dd7c3ac77514357d35ece7b1ba8b5f3e6c92735198aa6159c355c8", size = 2767914, upload-time = "2026-09-09T23:55:37.14Z" },
{ url = "https://files.pythonhosted.org/packages/5f/32/897c074cb99fbdda7d34b0a2546097a59162bb3d04c0d546ae4ec82345e3/psycopg2_binary-2.9.13-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2ca263643ae37998ae04d18e431df34d0d61f12b47640dab585f14b6dbe00798", size = 3725788, upload-time = "2026-09-09T23:55:39.04Z" },
{ url = "https://files.pythonhosted.org/packages/0f/f4/e3a789de34c9ac25d20b25c2be583da16394a2ba0926da1c863653831f41/psycopg2_binary-2.9.13-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c0214c7da18a28d108aa7108c8a3cca8035c7911ec97ef9ec0827569c9a2720", size = 3818111, upload-time = "2026-09-09T23:55:40.979Z" },
{ url = "https://files.pythonhosted.org/packages/72/29/647724c43ac510dbc59b80e20e85d439deb94f5d5a024153c32330fa041d/psycopg2_binary-2.9.13-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5d89e064bb12b40cad696cf4975e6da86f8c60f14cd06cb6c1bc0a7f5d01761f", size = 4586235, upload-time = "2026-09-09T23:55:43.012Z" },
{ url = "https://files.pythonhosted.org/packages/91/ad/7f52f92cc65c23778daff7eec4ee2099236694a0a4723a5f180d0708b607/psycopg2_binary-2.9.13-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:190c18b97d9ef72f2e88c451b6588af90d6bd7bf54cb94b963280dc86a2c7076", size = 4282424, upload-time = "2026-09-09T23:55:44.843Z" },
{ url = "https://files.pythonhosted.org/packages/3d/2a/1a472059b198942d99651656e2bc610575584478bfe68d297ecabbd4887f/psycopg2_binary-2.9.13-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c00ebe9a2f31151aade0db233dc1446513a95e92c39ce055ee097af0ae86be1c", size = 5902365, upload-time = "2026-09-09T23:55:46.619Z" },
{ url = "https://files.pythonhosted.org/packages/91/1a/171ea5dac7b3a0fa57b3cb59c2ad6d7b8bc60732368fecfd2ed1f1288392/psycopg2_binary-2.9.13-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5085f7ff7b1e890f279577cedeb8c628957869a340fa34a39f7f406500b3c916", size = 4119938, upload-time = "2026-09-09T23:55:49.381Z" },
{ url = "https://files.pythonhosted.org/packages/41/ce/3c6d4ad71853a59eee6a575fe36df4bb40752a9735a27bd62af66b454ed5/psycopg2_binary-2.9.13-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4e55357d1943673d491bbabb171c891704fc6a22441fea539e05a5c27a79ea3c", size = 3661557, upload-time = "2026-09-09T23:55:51.269Z" },
{ url = "https://files.pythonhosted.org/packages/10/a3/1819a01bf951eab2afb5ca2a3d11f50500bf536fecff088154372a8d1985/psycopg2_binary-2.9.13-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:3e60b06ec7f9dc3e5f1106d12706514b6d6b92c3dc438fcdf4e43e65cc660d1b", size = 3307770, upload-time = "2026-09-09T23:55:53.196Z" },
{ url = "https://files.pythonhosted.org/packages/4e/df/22f4aec952cd5b2dd02f438399583ed69f7d04b90e7c31659d9571bbe188/psycopg2_binary-2.9.13-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:dde942b46ce20f6c4464cdf551f3293207f803f4e4354454eb1f5599c3eb1fa1", size = 3052935, upload-time = "2026-09-09T23:55:55.117Z" },
{ url = "https://files.pythonhosted.org/packages/95/42/aab651bc22bafa961806ca3b21027bb0739a2730b0e6f7f0778baeb95e67/psycopg2_binary-2.9.13-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:215777c62ce81c3b487cefdb6a41969944eb982309f91349ff3ca0323d6f17ed", size = 3355387, upload-time = "2026-09-09T23:55:57.366Z" },
{ url = "https://files.pythonhosted.org/packages/bc/af/3b8220633eaf955e95ea7be67d76e81a0d1cd3c76362ea504b91ffa079db/psycopg2_binary-2.9.13-cp314-cp314-win_amd64.whl", hash = "sha256:f3088eb80f58ed933c62d87128741d31e786edc862e23266d3c286763d646de0", size = 2863890, upload-time = "2026-09-09T23:55:59.056Z" },
{ url = "https://files.pythonhosted.org/packages/6e/f1/377d17fc8425220d17552691cd2b97aa232da92173f5dead71278b83f8ab/psycopg2_binary-2.9.13-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:38397def2d794ffde9db80f63d6820253e61b17483112652a318355f51a56f50", size = 3725913, upload-time = "2026-09-09T23:56:00.736Z" },
{ url = "https://files.pythonhosted.org/packages/67/64/27208e67cd6e663f69bf7bf905cf69db066a015c90ac9ca948a56a8e9d78/psycopg2_binary-2.9.13-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:dff5c70ed9789ccb0d97ff4a7da51dc523a255c4ec95df188fa5d44adcae4ea8", size = 3818303, upload-time = "2026-09-09T23:56:02.551Z" },
{ url = "https://files.pythonhosted.org/packages/6b/98/67d2f34a1d18367b5f655bdd101759f8474286c74ffe701b7d6e3abd7fda/psycopg2_binary-2.9.13-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:08d3b81a6a91775c937abf97d4c58fc9142e8e35fb91c387d24f81d15c98e6cf", size = 4586508, upload-time = "2026-09-09T23:56:04.706Z" },
{ url = "https://files.pythonhosted.org/packages/bb/47/46c227deaf322dceafa0b7b321b4e5de9cc797014b7a353349b2e09b1118/psycopg2_binary-2.9.13-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:541a487a9ccd72b5e38f37f27b0ce78cb7eb3e336e7b5277d45463010c03a7a8", size = 4282383, upload-time = "2026-09-09T23:56:06.678Z" },
{ url = "https://files.pythonhosted.org/packages/f4/3c/e8705ffa381160d842eaf06a8446e8416f1a2497dd70a7e62277f3be6e7a/psycopg2_binary-2.9.13-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:562fe2a43b30e781848dce63d9080c15414c777c96df348c4342558338cc7bf3", size = 5902765, upload-time = "2026-09-09T23:56:08.634Z" },
{ url = "https://files.pythonhosted.org/packages/53/cc/359821c18317228b8032456a3740c98045b719ed003a594b9ebac9330b86/psycopg2_binary-2.9.13-cp315-cp315-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dddfe650e7dda464d676c27fbedb5061f1ad05e1604627f54c770d7f799d36e9", size = 4122849, upload-time = "2026-09-09T23:56:10.671Z" },
{ url = "https://files.pythonhosted.org/packages/17/e5/4d935acb6d3258c7a767b3d527e54c0b537649101b55002a5dbcfe747e2a/psycopg2_binary-2.9.13-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:4ff0f575cbb14f30445858dcfdd751e043486f5290915df78a9818bc74042eff", size = 3662084, upload-time = "2026-09-09T23:56:12.316Z" },
{ url = "https://files.pythonhosted.org/packages/89/56/9e9bbc7c773c5de7bb25dd35d7f041c2a6f0fcfa9207a1ceaf01a1bc687c/psycopg2_binary-2.9.13-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:d79530b4c1af657d5620a1d21b8e39f2996aa06821d5564d05b22d6b8cd413d0", size = 3308077, upload-time = "2026-09-09T23:56:15.262Z" },
{ url = "https://files.pythonhosted.org/packages/36/fa/ed742cd4e5dbddcb44702f9c4a97f7f5b62d97e3d9d00907ecc8ac750ef4/psycopg2_binary-2.9.13-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:6ede8595767e19d30a7e8a84a7d47bfde6176d45d194fed08dbb68d1584a780b", size = 3055143, upload-time = "2026-09-09T23:56:17.168Z" },
{ url = "https://files.pythonhosted.org/packages/d5/3a/5c2cb71a844ee236be2ce91b286d797e34a21489909357c7cfba0f5c0197/psycopg2_binary-2.9.13-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:0ebcf3c4266a695df9d0ef51296155f60c86ac51cf82f0d0dd2e827255a891c5", size = 3355374, upload-time = "2026-09-09T23:56:18.793Z" },
{ url = "https://files.pythonhosted.org/packages/e8/30/3991c9fdcca90a5a1e55435292f4d74d176da2be15f3998f6858da3658cc/psycopg2_binary-2.9.13-cp315-cp315-win_amd64.whl", hash = "sha256:1752b9821f1377404d65ac43af03d59a1eccc57fb2c1eb8305f9a3fe8eb7a8ba", size = 2867246, upload-time = "2026-09-09T23:56:20.501Z" },
]
[[package]]