Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69a09f4400 | ||
|
|
b2e36f630a | ||
|
|
6c16fdda03 | ||
|
|
2760854db9 | ||
|
|
d0d2ee1b00 | ||
|
|
6987d947d3 | ||
|
|
74780fef02 | ||
|
|
22f2c007e9 | ||
|
|
7892a5d79f | ||
|
|
c6c928d528 | ||
|
|
1213b1587e |
+99
-1
@@ -5,6 +5,104 @@ 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.02.1] - 2026-10-02
|
||||
|
||||
The identity resolver's second run is opt-in again for any resolver that
|
||||
answers.
|
||||
|
||||
### Changed
|
||||
- The second resolver run after the tool now happens only when the first
|
||||
answer was empty (no keys, only None values, or a run that failed or timed
|
||||
out) or lacks a key in `late_identity_keys`. In 2026.10.02 a missing
|
||||
`user_sub`, `caller_kind` or `client_id` also triggered it, so a resolver
|
||||
that never returns one of them (mcspeak's returns no `client_id`) ran twice
|
||||
on every kept call. `late_identity_keys` stays empty by default: a resolver
|
||||
that answers anything runs once per call unless the server opts in.
|
||||
blender-mcp's registration fix still needs
|
||||
`late_identity_keys=["bus_client_uuid"]`, as in 2026.10.02.
|
||||
|
||||
## [2026.10.02] - 2026-10-02
|
||||
|
||||
A tool that creates its caller's identity records it on its own row again.
|
||||
|
||||
### Fixed
|
||||
- Regression in 2026.10.01.4: with the identity resolver moved before the
|
||||
tool, a call whose tool creates the identity the resolver looks up (the
|
||||
call that registers a client, or logs in) recorded its own row without
|
||||
it. blender-mcp's `register_client` lost `bus_client_uuid`, `bus_role` and
|
||||
`parent_uuid` from its row's `identity` this way. When the first answer is
|
||||
incomplete, missing `user_sub`, `caller_kind` or `client_id`, or any key in
|
||||
the new `late_identity_keys`, the resolver now runs a second time after the
|
||||
tool, concurrently with the enricher, for calls that are kept (not for
|
||||
sampled-out ones). Its values fill the row's gaps and replace `None`; on
|
||||
conflict the first answer wins, so the row matches the events recorded
|
||||
during the call, which are not rewritten. Both runs keep `hook_timeout`,
|
||||
redaction and never raising. A complete first answer still means one run.
|
||||
|
||||
### Added
|
||||
- `late_identity_keys` on `InstrumentationMiddleware` and `instrument()`:
|
||||
identity keys that may only exist once the tool has run. A resolver that
|
||||
already returns all three columns before such a tool (blender-mcp's does)
|
||||
needs it to get the second run, e.g.
|
||||
`late_identity_keys=["bus_client_uuid"]`.
|
||||
|
||||
## [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 +584,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
@@ -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
|
||||
|
||||
|
||||
@@ -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 tests/unit/test_late_identity.py
|
||||
|
||||
@@ -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,44 @@ 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 **before** the tool. Events the tool records get
|
||||
the same `user_sub`, `caller_kind` and `client_id` as the call's row. 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.
|
||||
|
||||
Some tools create the identity they run under: a registration that makes the
|
||||
client record the resolver looks up, or a login. The resolver runs a second
|
||||
time after the tool, alongside the enricher and only for calls that are kept,
|
||||
when its first answer was empty (nothing, only None values, or a run that
|
||||
failed or timed out) or lacks a key named in `late_identity_keys`. A missing
|
||||
`user_sub`, `caller_kind` or `client_id` alone does not trigger it, and
|
||||
`late_identity_keys` is empty by default, so a resolver that answers anything
|
||||
runs once per call unless you opt in.
|
||||
The call's own row takes the second answer's values where the first had
|
||||
none; where both have a value, the first wins, so the row agrees with the
|
||||
events recorded during the call. Those events are not rewritten. A complete
|
||||
first answer means one run.
|
||||
|
||||
```python
|
||||
registered: dict[str, str] = {} # session -> bus client, filled by a tool
|
||||
|
||||
def who(context):
|
||||
out = {"user_sub": "user-1", "caller_kind": "addon", "client_id": "dcr-9"}
|
||||
session = getattr(context.fastmcp_context, "session_id", None)
|
||||
if session in registered:
|
||||
out["bus_client_uuid"] = registered[session]
|
||||
return out
|
||||
|
||||
instrument(app, identity_resolver=who, late_identity_keys=["bus_client_uuid"])
|
||||
```
|
||||
|
||||
Without `late_identity_keys` this resolver's first answer is not empty, so
|
||||
there is no second run and the call that registers the client is recorded
|
||||
without `bus_client_uuid`.
|
||||
|
||||
### Linking feedback to the calls behind it
|
||||
|
||||
When a model reports "the render tool hung", the report is far more useful with
|
||||
@@ -504,8 +573,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 +591,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 +934,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 +947,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 +1088,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 +1098,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
@@ -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.02.1" # 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",
|
||||
|
||||
@@ -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.02.1"
|
||||
__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:
|
||||
|
||||
@@ -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,20 @@ 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 before
|
||||
the tool, so events recorded during the call get that 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.
|
||||
When that first answer is empty (nothing, or only None values,
|
||||
including a run that failed or timed out) or lacks any of
|
||||
``late_identity_keys``, it runs again after the tool, alongside
|
||||
the enricher, and the call's own record takes the second
|
||||
answer's values where the first had none. Any other answer means
|
||||
one run, even without all three columns.
|
||||
late_identity_keys: identity keys that may only exist once the tool
|
||||
has run, such as an id a registration or login tool creates. A
|
||||
first answer without one of them gets the second run. Empty by
|
||||
default.
|
||||
enricher: ``(tool, args, result, context) -> dict`` of extra fields,
|
||||
stored in ``extra``. A ``server_version`` key fills that column.
|
||||
May be async.
|
||||
@@ -185,6 +198,7 @@ class InstrumentationMiddleware(Middleware):
|
||||
redactor: Redactor | None = None,
|
||||
meta_only_tools: Iterable[str] = (),
|
||||
identity_resolver: IdentityResolver | None = None,
|
||||
late_identity_keys: Iterable[str] = (),
|
||||
enricher: Enricher | None = None,
|
||||
server_version: str | None = None,
|
||||
hook_timeout: float = 0.25,
|
||||
@@ -209,6 +223,7 @@ class InstrumentationMiddleware(Middleware):
|
||||
self.redactor = redactor or Redactor()
|
||||
self.meta_only_tools = frozenset(meta_only_tools)
|
||||
self.identity_resolver = identity_resolver
|
||||
self.late_identity_keys = tuple(late_identity_keys)
|
||||
self.enricher = enricher
|
||||
self.server_version = server_version
|
||||
self.hook_timeout = hook_timeout
|
||||
@@ -257,8 +272,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 +282,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.column("user_sub"),
|
||||
caller_kind=who.column("caller_kind"),
|
||||
client_id=who.column("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 +309,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 +321,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 +339,43 @@ 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. Never raises.
|
||||
|
||||
Returns the redacted result split into the three columns and the
|
||||
rest, plus the FastMCP context's ``client_id`` as the fallback for a
|
||||
resolver that gives none.
|
||||
"""
|
||||
fallback = None
|
||||
try:
|
||||
fallback = _attr(context.fastmcp_context, "client_id")
|
||||
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}
|
||||
return _Identity(columns, identity, fallback)
|
||||
except Exception:
|
||||
logger.warning("Failed to resolve identity", exc_info=True)
|
||||
return _Identity({}, {}, fallback)
|
||||
|
||||
def _identity_incomplete(self, who: "_Identity") -> bool:
|
||||
"""Whether the resolver's first answer lacks something it may know later."""
|
||||
if self.identity_resolver is None:
|
||||
return False
|
||||
answered = any(
|
||||
v is not None for v in (*who.columns.values(), *who.rest.values())
|
||||
)
|
||||
return not answered or any(
|
||||
who.rest.get(k) is None and who.columns.get(k) is None
|
||||
for k in self.late_identity_keys
|
||||
)
|
||||
|
||||
async def _call_hook(self, hook: Callable | None, *args: Any) -> dict[str, Any]:
|
||||
if hook is None:
|
||||
return {}
|
||||
@@ -360,16 +413,24 @@ 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)
|
||||
extra = await self._call_hook(self.enricher, tool, args, payload, context)
|
||||
identity = self.redactor.redact(identity)
|
||||
enrich = self._call_hook(self.enricher, tool, args, payload, context)
|
||||
if self._identity_incomplete(who):
|
||||
# A tool can create the identity it runs under (a registration,
|
||||
# a login). Ask again, concurrently with the enricher so the
|
||||
# record is not built any later than the slower of the two.
|
||||
later, extra = await asyncio.gather(self._resolve_identity(context), enrich)
|
||||
who = who.filled_from(later)
|
||||
else:
|
||||
extra = await enrich
|
||||
identity = dict(who.rest)
|
||||
columns = {k: who.column(k) for k in _IDENTITY_COLUMNS}
|
||||
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 +464,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 +487,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 +534,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 +547,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 +859,45 @@ class InstrumentationMiddleware(Middleware):
|
||||
await self.dispatcher.aclose()
|
||||
|
||||
|
||||
class _Identity(NamedTuple):
|
||||
"""One answer from the identity resolver, redacted.
|
||||
|
||||
``columns`` holds whichever of user_sub, caller_kind and client_id it
|
||||
returned, ``rest`` everything else, and ``fallback_client_id`` the
|
||||
FastMCP context's client_id for when it returned none.
|
||||
"""
|
||||
|
||||
columns: dict[str, Any]
|
||||
rest: dict[str, Any]
|
||||
fallback_client_id: str | None = None
|
||||
|
||||
def column(self, key: str) -> Any:
|
||||
value = self.columns.get(key)
|
||||
if key == "client_id" and not value:
|
||||
return self.fallback_client_id
|
||||
return value
|
||||
|
||||
def filled_from(self, later: "_Identity") -> "_Identity":
|
||||
"""This answer, with gaps and None values filled from ``later``.
|
||||
|
||||
Values already here win, so the call's record agrees with the events
|
||||
recorded during the call, which saw only this answer.
|
||||
"""
|
||||
|
||||
def fill(first: dict[str, Any], second: dict[str, Any]) -> dict[str, Any]:
|
||||
merged = dict(first)
|
||||
for key, value in second.items():
|
||||
if merged.get(key) is None:
|
||||
merged[key] = value
|
||||
return merged
|
||||
|
||||
return _Identity(
|
||||
fill(self.columns, later.columns),
|
||||
fill(self.rest, later.rest),
|
||||
self.fallback_client_id or later.fallback_client_id,
|
||||
)
|
||||
|
||||
|
||||
def _is_sink(sink: Any, module: str, name: str) -> bool:
|
||||
"""isinstance against a sink class without importing its module.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -0,0 +1,524 @@
|
||||
"""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
|
||||
# An empty first answer gets a second run after the tool; it fails too.
|
||||
assert calls == [1, 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
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
"""The identity resolver's second run, after the tool, for identity the tool creates.
|
||||
|
||||
Modelled on blender-mcp: an addon calls register_client, and only after that
|
||||
call can the resolver find the bus client (bus_client_uuid, bus_role) for the
|
||||
caller's session. The first run, before the tool, already knows the user.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import time
|
||||
|
||||
from fastmcp import Client, FastMCP
|
||||
from fastmcp.exceptions import ToolError
|
||||
|
||||
from fastmcp_feedback.instrumentation import REDACTED, MemorySink, instrument
|
||||
|
||||
|
||||
class Bus:
|
||||
"""Who is registered, as the tool leaves it; read by the resolvers."""
|
||||
|
||||
def __init__(self):
|
||||
self.client = None
|
||||
|
||||
|
||||
class BlenderResolver:
|
||||
"""blender-mcp's identify(): user and role always, bus keys once registered."""
|
||||
|
||||
def __init__(self, bus):
|
||||
self.bus = bus
|
||||
self.calls = 0
|
||||
|
||||
def __call__(self, context):
|
||||
self.calls += 1
|
||||
out = {"user_sub": "user-1", "caller_kind": "addon", "client_id": "dcr-9"}
|
||||
if self.bus.client is not None:
|
||||
out["bus_client_uuid"] = self.bus.client
|
||||
out["bus_role"] = "interactive"
|
||||
return out
|
||||
|
||||
|
||||
class EmptyUntilRegistered:
|
||||
"""Nothing before registration, every field after."""
|
||||
|
||||
def __init__(self, bus):
|
||||
self.bus = bus
|
||||
self.calls = 0
|
||||
|
||||
def __call__(self, context):
|
||||
self.calls += 1
|
||||
if self.bus.client is None:
|
||||
return {}
|
||||
return {
|
||||
"user_sub": "user-1",
|
||||
"caller_kind": "addon",
|
||||
"client_id": "dcr-9",
|
||||
"bus_client_uuid": self.bus.client,
|
||||
}
|
||||
|
||||
|
||||
def make_app(resolver_cls, *, late=(), sample=None, **kwargs):
|
||||
bus = Bus()
|
||||
resolver = resolver_cls(bus)
|
||||
sink = MemorySink()
|
||||
app = FastMCP("late-identity")
|
||||
holder = {}
|
||||
|
||||
@app.tool
|
||||
async def register_client(client_uuid: str) -> dict:
|
||||
holder["mw"].record_event("bus.registering", key=client_uuid)
|
||||
bus.client = client_uuid
|
||||
return {"status": "ok"}
|
||||
|
||||
@app.tool
|
||||
async def register_then_fail(client_uuid: str) -> dict:
|
||||
bus.client = client_uuid
|
||||
raise RuntimeError("registered, then broke")
|
||||
|
||||
@app.tool
|
||||
async def quiet() -> str:
|
||||
return "ok"
|
||||
|
||||
@app.tool
|
||||
async def ping() -> str:
|
||||
holder["mw"].record_event("bus.ping")
|
||||
return "pong"
|
||||
|
||||
kwargs.setdefault("identity_resolver", resolver)
|
||||
if sample is not None:
|
||||
kwargs.update(
|
||||
sample_rates={"ping": sample, "quiet": sample}, random=lambda: 0.99
|
||||
)
|
||||
mw = instrument(app, [sink], late_identity_keys=late, **kwargs)
|
||||
holder["mw"] = mw
|
||||
return app, sink, mw, resolver
|
||||
|
||||
|
||||
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()
|
||||
|
||||
|
||||
def columns(record):
|
||||
return (record.user_sub, record.caller_kind, record.client_id)
|
||||
|
||||
|
||||
class TestRegistration:
|
||||
async def test_blender_shaped_registration_row_gets_bus_identity(self):
|
||||
app, sink, mw, who = make_app(BlenderResolver, late=("bus_client_uuid",))
|
||||
await call(app, mw, ("register_client", {"client_uuid": "c-1"}))
|
||||
[rec] = sink.calls
|
||||
assert columns(rec) == ("user-1", "addon", "dcr-9")
|
||||
assert rec.identity == {"bus_client_uuid": "c-1", "bus_role": "interactive"}
|
||||
assert who.calls == 2
|
||||
# The event recorded during the call saw the first answer only.
|
||||
[ev] = sink.events
|
||||
assert ev.call_id == rec.id and columns(ev) == ("user-1", "addon", "dcr-9")
|
||||
|
||||
async def test_blender_shaped_without_late_keys_runs_once(self):
|
||||
# The first answer is not empty, so without late_identity_keys there
|
||||
# is no second run and the row lacks the bus keys. Opt-in by design.
|
||||
app, sink, mw, who = make_app(BlenderResolver)
|
||||
await call(app, mw, ("register_client", {"client_uuid": "c-1"}))
|
||||
[rec] = sink.calls
|
||||
assert rec.identity == {} and "bus_client_uuid" not in rec.identity
|
||||
assert columns(rec) == ("user-1", "addon", "dcr-9")
|
||||
assert who.calls == 1
|
||||
|
||||
async def test_registered_caller_runs_once(self):
|
||||
app, sink, mw, who = make_app(BlenderResolver, late=("bus_client_uuid",))
|
||||
await call(
|
||||
app,
|
||||
mw,
|
||||
("register_client", {"client_uuid": "c-1"}),
|
||||
("ping", {}),
|
||||
("ping", {}),
|
||||
)
|
||||
assert who.calls == 2 + 1 + 1
|
||||
assert [r.identity.get("bus_client_uuid") for r in sink.calls] == [
|
||||
"c-1",
|
||||
"c-1",
|
||||
"c-1",
|
||||
]
|
||||
|
||||
async def test_empty_first_answer_filled_after(self):
|
||||
app, sink, mw, who = make_app(EmptyUntilRegistered)
|
||||
await call(app, mw, ("register_client", {"client_uuid": "c-2"}))
|
||||
[rec] = sink.calls
|
||||
[ev] = sink.events
|
||||
assert columns(rec) == ("user-1", "addon", "dcr-9")
|
||||
assert rec.identity == {"bus_client_uuid": "c-2"}
|
||||
assert columns(ev) == (None, None, None) # not rewritten
|
||||
assert who.calls == 2
|
||||
|
||||
async def test_tool_that_raises_gets_second_run_too(self):
|
||||
app, sink, mw, who = make_app(EmptyUntilRegistered)
|
||||
await call(app, mw, ("register_then_fail", {"client_uuid": "c-3"}))
|
||||
[rec] = sink.calls
|
||||
assert rec.outcome == "error"
|
||||
assert columns(rec) == ("user-1", "addon", "dcr-9")
|
||||
assert who.calls == 2
|
||||
|
||||
|
||||
class TestMerge:
|
||||
async def test_first_answer_wins_on_conflict(self):
|
||||
answers = iter(
|
||||
[
|
||||
{"user_sub": "early", "caller_kind": None, "org": None},
|
||||
{
|
||||
"user_sub": "late",
|
||||
"caller_kind": "k",
|
||||
"client_id": "c",
|
||||
"org": "acme",
|
||||
"team": "qa",
|
||||
},
|
||||
]
|
||||
)
|
||||
app, sink, mw, _ = make_app(
|
||||
EmptyUntilRegistered,
|
||||
identity_resolver=lambda c: next(answers),
|
||||
late=("team",),
|
||||
)
|
||||
await call(app, mw, ("ping", {}))
|
||||
[rec] = sink.calls
|
||||
[ev] = sink.events
|
||||
assert columns(rec) == ("early", "k", "c")
|
||||
assert rec.identity == {"org": "acme", "team": "qa"}
|
||||
assert columns(ev) == ("early", None, None)
|
||||
|
||||
async def test_second_answer_redacted(self):
|
||||
token = "bmcp_abcdefgh12345678"
|
||||
answers = iter([{}, {"user_sub": "u", "caller_kind": f"pat:{token}"}])
|
||||
app, sink, mw, _ = make_app(
|
||||
EmptyUntilRegistered, identity_resolver=lambda c: next(answers)
|
||||
)
|
||||
await call(app, mw, ("ping", {}))
|
||||
assert sink.calls[0].caller_kind == f"pat:{REDACTED}"
|
||||
|
||||
|
||||
class TestFailures:
|
||||
async def test_second_run_raising_keeps_first_answer(self):
|
||||
runs = []
|
||||
|
||||
def who(context):
|
||||
runs.append(1)
|
||||
if len(runs) == 1:
|
||||
return {"user_sub": "u1"}
|
||||
raise RuntimeError("resolver bug")
|
||||
|
||||
app, sink, mw, _ = make_app(
|
||||
EmptyUntilRegistered, identity_resolver=who, late=("bus_client_uuid",)
|
||||
)
|
||||
await call(app, mw, ("ping", {}))
|
||||
[rec] = sink.calls
|
||||
assert rec.ok and rec.user_sub == "u1" and len(runs) == 2
|
||||
|
||||
async def test_second_run_timing_out_keeps_first_answer(self):
|
||||
runs = []
|
||||
|
||||
async def who(context):
|
||||
runs.append(1)
|
||||
if len(runs) == 1:
|
||||
return {"user_sub": "u1"}
|
||||
await asyncio.sleep(5)
|
||||
return {"user_sub": "late", "caller_kind": "k"}
|
||||
|
||||
app, sink, mw, _ = make_app(
|
||||
EmptyUntilRegistered,
|
||||
identity_resolver=who,
|
||||
hook_timeout=0.05,
|
||||
late=("bus_client_uuid",),
|
||||
)
|
||||
await call(app, mw, ("ping", {}))
|
||||
[rec] = sink.calls
|
||||
assert rec.ok and rec.user_sub == "u1" and rec.caller_kind is None
|
||||
assert len(runs) == 2
|
||||
|
||||
|
||||
class McspeakResolver:
|
||||
"""mcspeak's _who: user_sub and caller_kind, never client_id."""
|
||||
|
||||
def __init__(self, bus):
|
||||
self.calls = 0
|
||||
|
||||
def __call__(self, context):
|
||||
self.calls += 1
|
||||
return {"caller_kind": "claude-code", "user_sub": "mctalkbox"}
|
||||
|
||||
|
||||
class TestWhenItRuns:
|
||||
async def test_missing_column_alone_does_not_rerun(self):
|
||||
app, sink, mw, who = make_app(McspeakResolver)
|
||||
await call(app, mw, ("ping", {}), ("quiet", {}), ("ping", {}))
|
||||
assert who.calls == 3 and len(sink.calls) == 3
|
||||
assert {columns(r) for r in sink.calls} == {("mctalkbox", "claude-code", None)}
|
||||
|
||||
async def test_only_none_values_count_as_empty(self):
|
||||
answers = iter([{"user_sub": None}, {"user_sub": "u2", "caller_kind": "k"}])
|
||||
app, sink, mw, _ = make_app(
|
||||
EmptyUntilRegistered, identity_resolver=lambda c: next(answers)
|
||||
)
|
||||
await call(app, mw, ("ping", {}))
|
||||
assert columns(sink.calls[0])[:2] == ("u2", "k")
|
||||
|
||||
async def test_complete_answer_runs_once(self):
|
||||
app, sink, mw, who = make_app(BlenderResolver)
|
||||
await call(app, mw, ("ping", {}), ("ping", {}))
|
||||
assert who.calls == 2 and len(sink.calls) == 2
|
||||
|
||||
async def test_sampled_out_call_makes_no_second_run(self):
|
||||
app, sink, mw, who = make_app(EmptyUntilRegistered, sample=0.1)
|
||||
await call(app, mw, ("quiet", {}))
|
||||
assert sink.calls == [] and who.calls == 1
|
||||
|
||||
async def test_sampled_call_kept_for_its_event_gets_second_run(self):
|
||||
app, sink, mw, who = make_app(EmptyUntilRegistered, sample=0.1)
|
||||
await call(app, mw, ("ping", {}))
|
||||
assert len(sink.calls) == 1 and who.calls == 2
|
||||
|
||||
async def test_no_resolver_no_runs(self):
|
||||
app, sink, mw, _ = make_app(EmptyUntilRegistered, identity_resolver=None)
|
||||
await call(app, mw, ("ping", {}))
|
||||
[rec] = sink.calls
|
||||
assert columns(rec) == (None, None, None)
|
||||
|
||||
async def test_second_run_overlaps_enricher(self):
|
||||
# The record is built before the result reaches the client, so the
|
||||
# second run must not add its time to the enricher's.
|
||||
runs = []
|
||||
|
||||
async def who(context):
|
||||
runs.append(1)
|
||||
if len(runs) > 1:
|
||||
await asyncio.sleep(0.4)
|
||||
return {}
|
||||
|
||||
async def enrich(tool, args, result, context):
|
||||
await asyncio.sleep(0.4)
|
||||
return {}
|
||||
|
||||
app, sink, mw, _ = make_app(
|
||||
EmptyUntilRegistered,
|
||||
identity_resolver=who,
|
||||
enricher=enrich,
|
||||
hook_timeout=2,
|
||||
)
|
||||
async with Client(app) as client:
|
||||
start = time.perf_counter()
|
||||
await client.call_tool("ping", {})
|
||||
elapsed = time.perf_counter() - start
|
||||
assert len(runs) == 2
|
||||
assert elapsed < 0.75, elapsed
|
||||
|
||||
|
||||
async def test_mode_off_runs_nothing():
|
||||
app, sink, mw, who = make_app(EmptyUntilRegistered, mode="off")
|
||||
await call(app, mw, ("ping", {}))
|
||||
assert who.calls == 0
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -601,7 +601,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "fastmcp-feedback"
|
||||
version = "2026.10.1.3"
|
||||
version = "2026.10.2.1"
|
||||
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]]
|
||||
|
||||
Reference in New Issue
Block a user