Compare commits
16 Commits
02c8f5d338
...
v0.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 9643011e64 | |||
| a6a2175f8b | |||
| 8c7dc7ab81 | |||
| cce17f3517 | |||
| 4b9280a972 | |||
| b95071a95e | |||
| 11acf0eb92 | |||
| ca9ff55587 | |||
| 7fe2d853ec | |||
| 964b10dfe8 | |||
| ec94fe899b | |||
| a91150c41f | |||
| 8c50ab008c | |||
| fab5569955 | |||
| 4792f0277f | |||
| 61e00028e8 |
17
.dockerignore
Normal file
17
.dockerignore
Normal file
@@ -0,0 +1,17 @@
|
||||
.git
|
||||
.venv
|
||||
__pycache__
|
||||
*.pyc
|
||||
.pytest_cache
|
||||
.ruff_cache
|
||||
.mypy_cache
|
||||
tests
|
||||
docs
|
||||
.env
|
||||
.env.*
|
||||
.idea
|
||||
.vscode
|
||||
*.egg-info
|
||||
dist
|
||||
build
|
||||
.coverage
|
||||
41
.gitea/workflows/ci.yml
Normal file
41
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,41 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- '**'
|
||||
tags-ignore:
|
||||
- '**'
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
runs-on: docker
|
||||
container:
|
||||
image: python:3.12-slim
|
||||
steps:
|
||||
- name: Install git
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends git ca-certificates
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
- name: Checkout
|
||||
run: |
|
||||
git clone "${{ gitea.server_url }}/${{ gitea.repository }}.git" .
|
||||
git checkout "${{ gitea.sha }}"
|
||||
|
||||
- name: Install uv
|
||||
run: pip install --no-cache-dir uv
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --frozen
|
||||
|
||||
- name: Lint
|
||||
run: uvx ruff check app tests
|
||||
|
||||
- name: Test
|
||||
run: uv run pytest
|
||||
|
||||
- name: Build distribution
|
||||
run: uv build
|
||||
67
.gitea/workflows/release.yml
Normal file
67
.gitea/workflows/release.yml
Normal file
@@ -0,0 +1,67 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: dind
|
||||
steps:
|
||||
- name: Install tooling
|
||||
run: apk add --no-cache git curl jq docker-cli docker-cli-buildx
|
||||
|
||||
- name: Clone repository at tag
|
||||
run: |
|
||||
git clone "https://${{ secrets.GITEA_TOKEN }}@gitea.jeanlucmakiola.de/${{ gitea.repository }}.git" repo
|
||||
cd repo
|
||||
git checkout "${{ gitea.ref_name }}"
|
||||
|
||||
- name: Compute previous tag and changelog
|
||||
working-directory: repo
|
||||
run: |
|
||||
VERSION="${{ gitea.ref_name }}"
|
||||
PREV_TAG=$(git tag -l 'v*' --sort=-v:refname | grep -v "^${VERSION}$" | head -n1 || true)
|
||||
if [ -z "$PREV_TAG" ]; then
|
||||
CHANGELOG=$(git log --pretty=format:"- %s" "${VERSION}")
|
||||
else
|
||||
CHANGELOG=$(git log --pretty=format:"- %s" "${PREV_TAG}..${VERSION}")
|
||||
fi
|
||||
{
|
||||
echo "VERSION=$VERSION"
|
||||
echo "PREV_TAG=$PREV_TAG"
|
||||
echo "CHANGELOG<<CHANGELOG_EOF"
|
||||
echo "$CHANGELOG"
|
||||
echo "CHANGELOG_EOF"
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: Log in to Gitea registry
|
||||
run: |
|
||||
echo "${{ secrets.REGISTRY_TOKEN }}" \
|
||||
| docker login gitea.jeanlucmakiola.de -u "${{ gitea.repository_owner }}" --password-stdin
|
||||
|
||||
- name: Build and push Docker image
|
||||
working-directory: repo
|
||||
run: |
|
||||
IMAGE="gitea.jeanlucmakiola.de/${{ gitea.repository }}"
|
||||
docker buildx create --use --name builder >/dev/null 2>&1 || docker buildx use builder
|
||||
docker buildx build \
|
||||
--cache-from "type=registry,ref=${IMAGE}:buildcache" \
|
||||
--cache-to "type=registry,ref=${IMAGE}:buildcache,mode=max" \
|
||||
-f docker/Dockerfile \
|
||||
-t "${IMAGE}:${VERSION}" \
|
||||
-t "${IMAGE}:latest" \
|
||||
--push .
|
||||
|
||||
- name: Create Gitea release
|
||||
run: |
|
||||
API_URL="${GITHUB_SERVER_URL}/api/v1/repos/${{ gitea.repository }}/releases"
|
||||
curl -fsS -X POST "$API_URL" \
|
||||
-H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$(jq -n \
|
||||
--arg tag "$VERSION" \
|
||||
--arg name "$VERSION" \
|
||||
--arg body "$CHANGELOG" \
|
||||
'{tag_name: $tag, name: $name, body: $body, draft: false, prerelease: false}')"
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -4,6 +4,8 @@ __pycache__/
|
||||
.venv/
|
||||
venv/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
|
||||
144
README.md
Normal file
144
README.md
Normal file
@@ -0,0 +1,144 @@
|
||||
# rag-ingestor
|
||||
|
||||
Microservice der Dateien aus Nextcloud (`Documents/THB/Studium/`) in Qdrant indexiert. Embeddings via Ollama.
|
||||
|
||||
## Endpoints
|
||||
|
||||
- `POST /webhook` (Header `X-Webhook-Secret`): Nextcloud-Event-Empfang (`created` / `updated` / `deleted`).
|
||||
- `POST /bulk-import` (Header `X-Webhook-Secret`): Body `{"path": "..."}` → rekursiver Re-Index. Bulk-Pipeline-Stages laufen mit Concurrency 4 (siehe `BULK_CONCURRENCY` in `app/bulk.py`).
|
||||
- `GET /health`: Liveness-Probe.
|
||||
|
||||
### Webhook-Payload-Format
|
||||
|
||||
Der Service erwartet ein vorgeformtes JSON. Nextcloud-Roh-Events werden **nicht** direkt akzeptiert — sie müssen via Flow-Webhook in dieses Schema übersetzt werden:
|
||||
|
||||
```json
|
||||
{
|
||||
"event_type": "created",
|
||||
"file_path": "Documents/THB/Studium/2.Semester/Databases/DBS1.pdf",
|
||||
"file_name": "DBS1.pdf"
|
||||
}
|
||||
```
|
||||
|
||||
`event_type` ∈ `{"created", "updated", "deleted"}`. Auth via Header `X-Webhook-Secret`, der mit `WEBHOOK_SECRET` aus der Konfiguration übereinstimmen muss.
|
||||
|
||||
Beispielaufruf:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/webhook \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Webhook-Secret: $WEBHOOK_SECRET" \
|
||||
-d '{"event_type": "created", "file_path": "Documents/THB/Studium/2.Semester/Databases/DBS1.pdf", "file_name": "DBS1.pdf"}'
|
||||
```
|
||||
|
||||
## Erwartete Ordnerstruktur
|
||||
|
||||
```
|
||||
Documents/THB/Studium/<N>.Semester/<Fach>/[<Unterordner>]/<datei>
|
||||
```
|
||||
|
||||
Unterstützte Dateitypen: `.pdf`, `.md`, `.docx`, `.xlsx` (XLSX wird nur als Filename indexiert, kein Inhalt).
|
||||
|
||||
## Konfiguration
|
||||
|
||||
Siehe `.env.example`. Alle Werte über Env-Vars, kein Config-File.
|
||||
|
||||
## Lokale Entwicklung
|
||||
|
||||
```bash
|
||||
uv sync
|
||||
uv run pytest
|
||||
uv run uvicorn app.main:app --reload
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
Image bauen und in Coolify neben Qdrant + Ollama deployen:
|
||||
|
||||
```bash
|
||||
docker build -f docker/Dockerfile -t rag-ingestor .
|
||||
```
|
||||
|
||||
### Ollama-Ressourcenlimits
|
||||
|
||||
Embedding-Inferenz ist CPU-only und skaliert per Default auf alle verfügbaren Cores. Für Produktion daher Ollama hart limitieren, damit der Host nicht von Ingest-Spikes blockiert wird:
|
||||
|
||||
- `cpus: "2.0"` (Container-Cap)
|
||||
- `OLLAMA_NUM_PARALLEL=1` (serialisiert Embedding-Requests intern)
|
||||
|
||||
Beide Werte sind in `docker-compose.yml` für die lokale Entwicklung gesetzt und sollten in Coolify entsprechend mitgepflegt werden. Folge: konstante ~2 CPU statt Peaks bis 8 CPU, dafür längere Bulk-Laufzeiten.
|
||||
|
||||
## MCP-Server (Retrieval via MetaMCP)
|
||||
|
||||
`app/mcp_server.py` exponiert das indexierte Wissen als MCP-Server (streamable-http, Endpoint `/mcp`). Er läuft aus **demselben Image** wie der Ingestor und nutzt denselben Embed-Pfad (Ollama `qwen3-embedding:0.6b`) — Query-Vektoren sind damit garantiert kompatibel zu den beim Ingest geschriebenen 1024-Dim-Vektoren. Der offizielle `qdrant/mcp-server-qdrant` ist *nicht* nutzbar: er kann nur fastembed (Dimension-/Modell-Mismatch), kennt unser Payload-Schema nicht und bietet kein Metadata-Filtering.
|
||||
|
||||
Tools:
|
||||
|
||||
- `rag_search(query, limit=5, semester?, fach?, typ?)` — semantische Suche mit optionalen Payload-Filtern; liefert `text` + Quell-Metadaten + `score`.
|
||||
- `get_file_chunks(file_path)` — alle Chunks eines Dokuments in `chunk_index`-Reihenfolge.
|
||||
|
||||
### Lokal starten
|
||||
|
||||
```bash
|
||||
RAG_MCP_TOKEN=dev-token uv run python -m app.mcp_server
|
||||
```
|
||||
|
||||
Lauscht auf `0.0.0.0:${RAG_MCP_PORT:-9009}`. Ohne `RAG_MCP_TOKEN` verweigert der Server den Start (Fail-Fast). Jeder Request braucht `Authorization: Bearer <RAG_MCP_TOKEN>`.
|
||||
|
||||
### Coolify + MetaMCP
|
||||
|
||||
Der `rag-mcp`-Service in `docker-compose.coolify.yml` ist bewusst **nicht public** (keine Domain / kein `expose`). Erreichbar ist er nur über das dedizierte externe Netz `metamcp-net` — qdrant/ollama/ingestor bleiben außen vor (kein Shared-Network-Coupling des ganzen Stacks).
|
||||
|
||||
1. Netz einmalig anlegen: `docker network create metamcp-net`
|
||||
2. In der MetaMCP-Compose `networks: [metamcp-net]` ergänzen, damit MetaMCP demselben Netz beitritt.
|
||||
3. `RAG_MCP_TOKEN` als Secret in Coolify setzen (Wert aus `.env.coolify`).
|
||||
4. In MetaMCP einen streamable-http-Downstream registrieren: URL `http://rag-mcp:9009/mcp`, Header `Authorization: Bearer <RAG_MCP_TOKEN>`.
|
||||
|
||||
Externe Kontrolle/Sharing macht MetaMCP als Single-Endpoint; der Token + die Netz-Isolation sind die zwei Kontrollschichten auf dem internen Hop.
|
||||
|
||||
### Webhook-Härtung
|
||||
|
||||
- **Rate-Limit:** `docker-compose.coolify.yml` definiert am `ingestor` eine Traefik-Middleware `rag-ratelimit` (30 req/min, Burst 15). Das *Binding* an den Coolify-generierten Router trägt eine env-spezifische UUID und gehört daher **nicht** ins Repo, sondern als Custom-Label in die Coolify-UI des ingestor-Service:
|
||||
|
||||
```
|
||||
traefik.http.routers.https-0-<COOLIFY-UUID>-ingestor.middlewares=gzip,rag-ratelimit
|
||||
```
|
||||
|
||||
(UUID aus der von Coolify gerenderten Compose entnehmen; bestehende `gzip`-Middleware beibehalten.)
|
||||
- **Bulk-Amplification-Guard:** `/bulk-import` lehnt mit `409` ab, solange ein vorheriger Bulk noch Tasks abarbeitet — verhindert unbegrenztes Anhäufen von BackgroundTasks durch wiederholte Calls oder einen fehlfeuernden Nextcloud-Flow.
|
||||
- CORS ist bewusst nicht konfiguriert: der Webhook wird Server-zu-Server gerufen, CORS ist kein Spam-Schutz und für Browser-Clients hier irrelevant.
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
uv run pytest -v
|
||||
```
|
||||
|
||||
Tests deckt Pure-Logic ab (Metadata-Parser, Chunker, Extractors, Auth, Pipeline-Orchestrierung mit gemockten externen Services). Keine Integration-Tests gegen echte Ollama/Qdrant/WebDAV-Instanzen.
|
||||
|
||||
## Recovery-Runbook
|
||||
|
||||
### Einbettungs-Modell oder -Dimension geändert
|
||||
|
||||
Beim Boot crasht der Service mit `qdrant collection ... dimension mismatch`, falls die existierende Collection eine andere Vektor-Dimension hat als das aktuelle Embedding-Modell. Dies ist Absicht (Fail-Fast). Vorgehen:
|
||||
|
||||
1. Collection in Qdrant manuell droppen:
|
||||
```bash
|
||||
curl -X DELETE "$QDRANT_URL/collections/$QDRANT_COLLECTION"
|
||||
```
|
||||
2. Service neu starten — Lifespan legt die Collection mit der neuen Dimension an.
|
||||
3. Bulk-Import auf den Studium-Root anstoßen, um alle Inhalte neu zu indexieren:
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/bulk-import \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Webhook-Secret: $WEBHOOK_SECRET" \
|
||||
-d '{"path": "Documents/THB/Studium"}'
|
||||
```
|
||||
|
||||
### Webhook-Ausfall / fehlende In-Flight-Jobs nach Crash
|
||||
|
||||
Der Service hat keinen persistenten Job-Store; In-Flight-`BackgroundTask`s gehen bei Crash verloren. Recovery erfolgt über den Bulk-Import-Endpoint auf den betroffenen Pfad (siehe oben).
|
||||
|
||||
### Ein einzelnes File neu indexieren
|
||||
|
||||
Webhook mit `event_type: "updated"` an `/webhook` POSTen — alte Chunks werden via `delete_by_filter(file_path)` entfernt, dann frisch indexiert.
|
||||
138
app/bulk.py
Normal file
138
app/bulk.py
Normal file
@@ -0,0 +1,138 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import PurePosixPath
|
||||
from urllib.parse import unquote
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, BackgroundTasks, Header, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.config import get_settings
|
||||
from app.ingest.extractors import SUPPORTED_TYPES
|
||||
from app.ingest.pipeline import process_file
|
||||
from app.webhook.auth import verify_secret
|
||||
from app.webhook.models import EventType
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# Limits parallel pipeline execution during a bulk import so we don't
|
||||
# saturate Ollama/WebDAV with thousands of concurrent requests.
|
||||
BULK_CONCURRENCY = 4
|
||||
_bulk_semaphore = asyncio.Semaphore(BULK_CONCURRENCY)
|
||||
|
||||
# Amplification guard: one /bulk-import dispatches one BackgroundTask per
|
||||
# matching file with no upper bound. Repeated calls (a misfiring Nextcloud
|
||||
# flow, or an attacker holding the secret) would pile unbounded tasks. We
|
||||
# track outstanding dispatched work and reject a new bulk while any is
|
||||
# still draining — bulk runs are rare, so serialising them is acceptable.
|
||||
_inflight = 0
|
||||
|
||||
|
||||
async def _process_with_semaphore(file_path: str, event_type: EventType) -> None:
|
||||
global _inflight
|
||||
try:
|
||||
async with _bulk_semaphore:
|
||||
await process_file(file_path, event_type)
|
||||
finally:
|
||||
_inflight -= 1
|
||||
|
||||
|
||||
class BulkRequest(BaseModel):
|
||||
path: str = Field(min_length=1)
|
||||
|
||||
|
||||
PROPFIND_BODY = """<?xml version="1.0"?>
|
||||
<d:propfind xmlns:d="DAV:"><d:prop><d:resourcetype/></d:prop></d:propfind>
|
||||
"""
|
||||
|
||||
DAV_NS = {"d": "DAV:"}
|
||||
|
||||
|
||||
async def list_files_recursive(base_url: str, user: str, password: str, path: str) -> list[str]:
|
||||
"""PROPFIND with Depth: infinity. Returns relative file paths (no folders)."""
|
||||
base = base_url.rstrip("/")
|
||||
rel = path.strip("/")
|
||||
url = f"{base}/{rel}"
|
||||
|
||||
async with httpx.AsyncClient(auth=(user, password), timeout=120.0) as client:
|
||||
response = await client.request(
|
||||
"PROPFIND",
|
||||
url,
|
||||
headers={"Depth": "infinity", "Content-Type": "application/xml"},
|
||||
content=PROPFIND_BODY,
|
||||
)
|
||||
if response.status_code not in (200, 207):
|
||||
raise RuntimeError(f"PROPFIND failed: status={response.status_code}")
|
||||
|
||||
root = ET.fromstring(response.text)
|
||||
base_path_segment = PurePosixPath(httpx.URL(base).path).as_posix() # "/remote.php/dav/files/u"
|
||||
out: list[str] = []
|
||||
for resp in root.findall("d:response", DAV_NS):
|
||||
href = resp.findtext("d:href", default="", namespaces=DAV_NS)
|
||||
decoded = unquote(href)
|
||||
# Strip the WebDAV base prefix → leaves "Documents/.../file.pdf"
|
||||
if decoded.startswith(base_path_segment):
|
||||
decoded = decoded[len(base_path_segment):]
|
||||
decoded = decoded.lstrip("/")
|
||||
if not decoded or decoded.endswith("/"):
|
||||
continue
|
||||
# Skip directory entries (those have <d:collection/> resourcetype)
|
||||
rt = resp.find("d:propstat/d:prop/d:resourcetype/d:collection", DAV_NS)
|
||||
if rt is not None:
|
||||
continue
|
||||
out.append(decoded)
|
||||
return out
|
||||
|
||||
|
||||
@router.post("/bulk-import", status_code=status.HTTP_202_ACCEPTED)
|
||||
async def bulk_import(
|
||||
body: BulkRequest,
|
||||
background: BackgroundTasks,
|
||||
x_webhook_secret: str | None = Header(default=None),
|
||||
):
|
||||
global _inflight
|
||||
settings = get_settings()
|
||||
verify_secret(x_webhook_secret, settings.webhook_secret)
|
||||
|
||||
if _inflight > 0:
|
||||
logger.warning(
|
||||
"bulk rejected: import already in progress",
|
||||
extra={"event": "bulk_rejected", "path": body.path, "inflight": _inflight},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="a bulk import is already in progress",
|
||||
)
|
||||
|
||||
try:
|
||||
files = await list_files_recursive(
|
||||
settings.nextcloud_webdav_url,
|
||||
settings.nextcloud_user,
|
||||
settings.nextcloud_app_password,
|
||||
body.path,
|
||||
)
|
||||
except (RuntimeError, httpx.HTTPError, ET.ParseError) as exc:
|
||||
logger.exception(
|
||||
"bulk listing failed",
|
||||
extra={"event": "bulk_listing_failed", "path": body.path, "error": str(exc)},
|
||||
)
|
||||
raise HTTPException(status_code=502, detail="webdav listing failed") from exc
|
||||
|
||||
dispatched = 0
|
||||
for f in files:
|
||||
ext = PurePosixPath(f).suffix.lstrip(".").lower()
|
||||
if ext not in SUPPORTED_TYPES:
|
||||
continue
|
||||
_inflight += 1
|
||||
background.add_task(_process_with_semaphore, f, EventType.CREATED)
|
||||
dispatched += 1
|
||||
|
||||
logger.info(
|
||||
"bulk dispatch",
|
||||
extra={"event": "bulk_dispatch", "path": body.path, "dispatched": dispatched, "total_listed": len(files)},
|
||||
)
|
||||
return {"status": "accepted", "dispatched": dispatched}
|
||||
@@ -23,6 +23,12 @@ class Settings(BaseSettings):
|
||||
chunk_overlap_words: int = 50
|
||||
log_level: str = "INFO"
|
||||
|
||||
# MCP server (app.mcp_server). Optional so the ingestor — which shares
|
||||
# this Settings model — is unaffected. The MCP server itself refuses to
|
||||
# start when rag_mcp_token is empty.
|
||||
rag_mcp_token: str = ""
|
||||
rag_mcp_port: int = 9009
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_settings() -> Settings:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from functools import lru_cache
|
||||
from pathlib import PurePosixPath
|
||||
@@ -49,6 +50,7 @@ async def process_file(file_path: str, event_type: EventType) -> None:
|
||||
)
|
||||
return
|
||||
|
||||
t0 = time.perf_counter()
|
||||
try:
|
||||
data = await download_file(
|
||||
settings.nextcloud_webdav_url,
|
||||
@@ -59,7 +61,13 @@ async def process_file(file_path: str, event_type: EventType) -> None:
|
||||
except Exception as exc:
|
||||
logger.exception("download failed", extra={"event": "download_failed", "file": file_path, "error": str(exc)})
|
||||
return
|
||||
download_ms = int((time.perf_counter() - t0) * 1000)
|
||||
logger.info(
|
||||
"download ok",
|
||||
extra={"event": "download", "status": "ok", "file": file_path, "duration_ms": download_ms, "bytes": len(data)},
|
||||
)
|
||||
|
||||
t0 = time.perf_counter()
|
||||
try:
|
||||
pages = extract(data, extension, filename=PurePosixPath(file_path).name)
|
||||
except UnsupportedFileType:
|
||||
@@ -68,6 +76,11 @@ async def process_file(file_path: str, event_type: EventType) -> None:
|
||||
except Exception as exc:
|
||||
logger.exception("extract failed", extra={"event": "extract_failed", "file": file_path, "error": str(exc)})
|
||||
return
|
||||
extract_ms = int((time.perf_counter() - t0) * 1000)
|
||||
logger.info(
|
||||
"extract ok",
|
||||
extra={"event": "extract", "status": "ok", "file": file_path, "duration_ms": extract_ms, "pages": len(pages)},
|
||||
)
|
||||
|
||||
chunks: list[tuple[str, int, int]] = [] # (text, page, chunk_index)
|
||||
chunk_index = 0
|
||||
@@ -87,11 +100,24 @@ async def process_file(file_path: str, event_type: EventType) -> None:
|
||||
delete_by_path(_qdrant_client(), settings.qdrant_collection, file_path)
|
||||
return
|
||||
|
||||
t0 = time.perf_counter()
|
||||
try:
|
||||
vectors = await embed_texts([c[0] for c in chunks], model=settings.ollama_embed_model)
|
||||
except Exception as exc:
|
||||
logger.exception("embed failed", extra={"event": "embed_failed", "file": file_path, "error": str(exc)})
|
||||
return
|
||||
embed_ms = int((time.perf_counter() - t0) * 1000)
|
||||
logger.info(
|
||||
"embed ok",
|
||||
extra={"event": "embed", "status": "ok", "file": file_path, "duration_ms": embed_ms, "chunks": len(vectors)},
|
||||
)
|
||||
|
||||
if len(vectors) != len(chunks):
|
||||
logger.error(
|
||||
"vector/chunk count mismatch",
|
||||
extra={"event": "embed_failed", "file": file_path, "vectors": len(vectors), "chunks": len(chunks)},
|
||||
)
|
||||
return
|
||||
|
||||
now_iso = datetime.now(timezone.utc).isoformat()
|
||||
file_name = PurePosixPath(file_path).name
|
||||
@@ -105,20 +131,28 @@ async def process_file(file_path: str, event_type: EventType) -> None:
|
||||
"semester": metadata.semester,
|
||||
"fach": metadata.fach,
|
||||
"typ": metadata.typ,
|
||||
"page": page,
|
||||
"page": page_num,
|
||||
"chunk_index": idx,
|
||||
"text": text,
|
||||
"ingested_at": now_iso,
|
||||
},
|
||||
)
|
||||
for vec, (text, page, idx) in zip(vectors, chunks)
|
||||
for vec, (text, page_num, idx) in zip(vectors, chunks)
|
||||
]
|
||||
|
||||
t0 = time.perf_counter()
|
||||
qdrant = _qdrant_client()
|
||||
delete_by_path(qdrant, settings.qdrant_collection, file_path)
|
||||
upsert_chunks(qdrant, settings.qdrant_collection, points)
|
||||
qdrant_ms = int((time.perf_counter() - t0) * 1000)
|
||||
|
||||
logger.info(
|
||||
"ingested",
|
||||
extra={"event": "ingest_done", "file": file_path, "chunks": len(points)},
|
||||
extra={
|
||||
"event": "ingest_done",
|
||||
"status": "ok",
|
||||
"file": file_path,
|
||||
"chunks": len(points),
|
||||
"duration_ms": qdrant_ms,
|
||||
},
|
||||
)
|
||||
|
||||
43
app/main.py
Normal file
43
app/main.py
Normal file
@@ -0,0 +1,43 @@
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from qdrant_client import QdrantClient
|
||||
|
||||
from app.config import get_settings
|
||||
from app.ingest.embedder import embedding_dimension
|
||||
from app.logging_setup import setup_logging
|
||||
from app.qdrant_store import ensure_collection
|
||||
from app.webhook.handler import router as webhook_router
|
||||
from app.bulk import router as bulk_router
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _startup_ensure_collection() -> None:
|
||||
settings = get_settings()
|
||||
dim = await embedding_dimension(settings.ollama_embed_model)
|
||||
client = QdrantClient(url=settings.qdrant_url)
|
||||
ensure_collection(client, settings.qdrant_collection, vector_size=dim)
|
||||
logger.info(
|
||||
"qdrant collection ready",
|
||||
extra={"event": "startup", "collection": settings.qdrant_collection, "dim": dim},
|
||||
)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
setup_logging(get_settings().log_level)
|
||||
await _startup_ensure_collection()
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(title="rag-ingestor", lifespan=lifespan)
|
||||
app.include_router(webhook_router)
|
||||
app.include_router(bulk_router)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
147
app/mcp_server.py
Normal file
147
app/mcp_server.py
Normal file
@@ -0,0 +1,147 @@
|
||||
"""MCP server exposing the THB-Studium RAG corpus.
|
||||
|
||||
Runs from the *same* image as the ingestor and reuses its embedding path
|
||||
(`app.ingest.embedder`), so query vectors are produced by the exact model
|
||||
used at ingest time — the only way Qdrant search returns meaningful hits.
|
||||
|
||||
Transport: streamable-http on ``/mcp``. A static bearer token gates every
|
||||
request; the token is the second control layer behind network isolation
|
||||
(the service is only reachable by MetaMCP over a dedicated bridge).
|
||||
"""
|
||||
|
||||
import hmac
|
||||
import logging
|
||||
import sys
|
||||
from functools import lru_cache
|
||||
|
||||
import uvicorn
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
from qdrant_client import QdrantClient
|
||||
from starlette.types import Receive, Scope, Send
|
||||
|
||||
from app.config import get_settings
|
||||
from app.ingest.embedder import embed_texts
|
||||
from app.logging_setup import setup_logging
|
||||
from app.qdrant_store import get_chunks_by_path, search_chunks
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
mcp = FastMCP("rag-thb")
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _qdrant() -> QdrantClient:
|
||||
return QdrantClient(url=get_settings().qdrant_url)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def rag_search(
|
||||
query: str,
|
||||
limit: int = 5,
|
||||
semester: str | None = None,
|
||||
fach: str | None = None,
|
||||
typ: str | None = None,
|
||||
) -> list[dict]:
|
||||
"""Semantische Suche im THB-Studium-Wissen (Vorlesungen, Übungen, Notizen).
|
||||
|
||||
Args:
|
||||
query: Natürlichsprachige Suchanfrage.
|
||||
limit: Maximale Trefferzahl (Default 5).
|
||||
semester: Optionaler Filter, z.B. "2.Semester".
|
||||
fach: Optionaler Filter, z.B. "Databases".
|
||||
typ: Optionaler Filter, z.B. "Vorlesungen" oder "Uebungen".
|
||||
|
||||
Returns:
|
||||
Treffer mit text und Quell-Metadaten (file_path, semester, fach,
|
||||
typ, page, chunk_index) plus Similarity-score, absteigend sortiert.
|
||||
"""
|
||||
settings = get_settings()
|
||||
vectors = await embed_texts([query], model=settings.ollama_embed_model)
|
||||
return search_chunks(
|
||||
_qdrant(),
|
||||
settings.qdrant_collection,
|
||||
vectors[0],
|
||||
limit=limit,
|
||||
semester=semester,
|
||||
fach=fach,
|
||||
typ=typ,
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def get_file_chunks(file_path: str) -> list[dict]:
|
||||
"""Alle Chunks eines Dokuments in Reihenfolge laden.
|
||||
|
||||
Nützlich, um nach einem rag_search-Treffer das vollständige Dokument
|
||||
zu rekonstruieren.
|
||||
|
||||
Args:
|
||||
file_path: Exakter Nextcloud-Pfad wie in rag_search-Treffern, z.B.
|
||||
"Documents/THB/2.Semester/Databases/Uebungen/01/Loesung.pdf".
|
||||
|
||||
Returns:
|
||||
Chunks mit chunk_index, page und text, nach chunk_index sortiert.
|
||||
"""
|
||||
settings = get_settings()
|
||||
return get_chunks_by_path(_qdrant(), settings.qdrant_collection, file_path)
|
||||
|
||||
|
||||
class BearerAuthMiddleware:
|
||||
"""Pure-ASGI gate: constant-time check of ``Authorization: Bearer <token>``.
|
||||
|
||||
Non-HTTP scopes (lifespan, websocket) pass straight through so the
|
||||
StreamableHTTP session manager's lifespan still runs.
|
||||
"""
|
||||
|
||||
def __init__(self, app, token: str) -> None:
|
||||
self._app = app
|
||||
self._expected = f"Bearer {token}"
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
if scope["type"] != "http":
|
||||
await self._app(scope, receive, send)
|
||||
return
|
||||
|
||||
headers = dict(scope.get("headers") or [])
|
||||
provided = headers.get(b"authorization", b"").decode()
|
||||
if not hmac.compare_digest(provided, self._expected):
|
||||
await send(
|
||||
{
|
||||
"type": "http.response.start",
|
||||
"status": 401,
|
||||
"headers": [(b"content-type", b"text/plain")],
|
||||
}
|
||||
)
|
||||
await send({"type": "http.response.body", "body": b"unauthorized"})
|
||||
return
|
||||
|
||||
await self._app(scope, receive, send)
|
||||
|
||||
|
||||
def build_app():
|
||||
"""Token-gated ASGI app, or exit if RAG_MCP_TOKEN is unset."""
|
||||
settings = get_settings()
|
||||
if not settings.rag_mcp_token:
|
||||
logger.error(
|
||||
"refusing to start: RAG_MCP_TOKEN is empty",
|
||||
extra={"event": "mcp_startup_abort"},
|
||||
)
|
||||
sys.exit(1)
|
||||
mcp.settings.host = "0.0.0.0"
|
||||
mcp.settings.port = settings.rag_mcp_port
|
||||
return BearerAuthMiddleware(mcp.streamable_http_app(), settings.rag_mcp_token)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
settings = get_settings()
|
||||
setup_logging(settings.log_level)
|
||||
app = build_app()
|
||||
logger.info(
|
||||
"mcp server starting",
|
||||
extra={"event": "mcp_startup", "port": settings.rag_mcp_port},
|
||||
)
|
||||
uvicorn.run(app, host="0.0.0.0", port=settings.rag_mcp_port)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -61,3 +61,84 @@ def delete_by_path(client: QdrantClient, name: str, file_path: str) -> None:
|
||||
)
|
||||
)
|
||||
client.delete(collection_name=name, points_selector=selector)
|
||||
|
||||
|
||||
_RESULT_FIELDS = (
|
||||
"text",
|
||||
"file_path",
|
||||
"file_name",
|
||||
"semester",
|
||||
"fach",
|
||||
"typ",
|
||||
"page",
|
||||
"chunk_index",
|
||||
)
|
||||
|
||||
|
||||
def _payload_filter(
|
||||
semester: str | None, fach: str | None, typ: str | None
|
||||
) -> qm.Filter | None:
|
||||
"""Build a Qdrant filter from optional metadata constraints, or None."""
|
||||
conditions = [
|
||||
qm.FieldCondition(key=key, match=qm.MatchValue(value=value))
|
||||
for key, value in (("semester", semester), ("fach", fach), ("typ", typ))
|
||||
if value
|
||||
]
|
||||
return qm.Filter(must=conditions) if conditions else None
|
||||
|
||||
|
||||
def search_chunks(
|
||||
client: QdrantClient,
|
||||
name: str,
|
||||
vector: list[float],
|
||||
limit: int,
|
||||
semester: str | None = None,
|
||||
fach: str | None = None,
|
||||
typ: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Vector search with optional metadata filtering.
|
||||
|
||||
Returns one dict per hit: the indexed payload fields plus the similarity
|
||||
``score``. Caller must pass a vector embedded with the *same* model used
|
||||
at ingest time, otherwise results are meaningless.
|
||||
"""
|
||||
response = client.query_points(
|
||||
collection_name=name,
|
||||
query=vector,
|
||||
limit=limit,
|
||||
query_filter=_payload_filter(semester, fach, typ),
|
||||
with_payload=True,
|
||||
)
|
||||
out: list[dict[str, Any]] = []
|
||||
for point in response.points:
|
||||
payload = point.payload or {}
|
||||
row: dict[str, Any] = {field: payload.get(field) for field in _RESULT_FIELDS}
|
||||
row["score"] = point.score
|
||||
out.append(row)
|
||||
return out
|
||||
|
||||
|
||||
def get_chunks_by_path(
|
||||
client: QdrantClient, name: str, file_path: str
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return every chunk of one document, ordered by ``chunk_index``."""
|
||||
points, _ = client.scroll(
|
||||
collection_name=name,
|
||||
scroll_filter=qm.Filter(
|
||||
must=[qm.FieldCondition(key="file_path", match=qm.MatchValue(value=file_path))]
|
||||
),
|
||||
limit=10_000,
|
||||
with_payload=True,
|
||||
with_vectors=False,
|
||||
)
|
||||
rows = [
|
||||
{
|
||||
"chunk_index": p.payload.get("chunk_index"),
|
||||
"page": p.payload.get("page"),
|
||||
"text": p.payload.get("text"),
|
||||
}
|
||||
for p in points
|
||||
if p.payload is not None
|
||||
]
|
||||
rows.sort(key=lambda r: r["chunk_index"] if r["chunk_index"] is not None else 0)
|
||||
return rows
|
||||
|
||||
20
app/webhook/handler.py
Normal file
20
app/webhook/handler.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from fastapi import APIRouter, BackgroundTasks, Header, status
|
||||
|
||||
from app.config import get_settings
|
||||
from app.ingest.pipeline import process_file
|
||||
from app.webhook.auth import verify_secret
|
||||
from app.webhook.models import NextcloudEvent
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/webhook", status_code=status.HTTP_202_ACCEPTED)
|
||||
async def webhook(
|
||||
event: NextcloudEvent,
|
||||
background: BackgroundTasks,
|
||||
x_webhook_secret: str | None = Header(default=None),
|
||||
):
|
||||
verify_secret(x_webhook_secret, get_settings().webhook_secret)
|
||||
background.add_task(process_file, event.file_path, event.event_type)
|
||||
return {"status": "accepted"}
|
||||
155
docker-compose.coolify.yml
Normal file
155
docker-compose.coolify.yml
Normal file
@@ -0,0 +1,155 @@
|
||||
# Coolify production stack — self-contained: qdrant + ollama + ingestor + rag-mcp.
|
||||
#
|
||||
# Services kommunizieren nur über das compose-interne Netz. Öffentlich ist
|
||||
# allein der ingestor (Coolify-Domain → Port 8000) für den Nextcloud-Webhook.
|
||||
# qdrant + ollama bleiben intern. rag-mcp ist NICHT public (keine Domain) —
|
||||
# nur MetaMCP erreicht ihn über das dedizierte externe Netz "metamcp-net".
|
||||
#
|
||||
# Coolify rendert container_name/labels/COOLIFY_*-env/das Stack-Netz selbst.
|
||||
# Hier stehen nur Extra-Labels und das, was zusätzlich nötig ist.
|
||||
#
|
||||
# Voraussetzungen in Coolify:
|
||||
# - Pull-Credentials für gitea.jeanlucmakiola.de Registry hinterlegen
|
||||
# - Externes Netz einmalig anlegen: docker network create metamcp-net
|
||||
# und es in der MetaMCP-Compose ergänzen (networks: [metamcp-net])
|
||||
# - Env-Vars setzen (Secrets wo markiert):
|
||||
# NEXTCLOUD_WEBDAV_URL
|
||||
# NEXTCLOUD_USER
|
||||
# NEXTCLOUD_APP_PASSWORD (Secret)
|
||||
# WEBHOOK_SECRET (Secret)
|
||||
# RAG_MCP_TOKEN (Secret — Bearer-Token für MetaMCP)
|
||||
# QDRANT_COLLECTION (optional, Default rag_thb_studium)
|
||||
# INGEST_ROOT (optional, Default Documents/THB)
|
||||
# LOG_LEVEL (optional, Default INFO)
|
||||
# - Traefik-Rate-Limit am ingestor-Router aktivieren: siehe README
|
||||
# ("MetaMCP & Härtung") — das Router-Binding-Label trägt die
|
||||
# env-spezifische Coolify-UUID und gehört daher in die Coolify-UI,
|
||||
# nicht ins versionierte Compose.
|
||||
|
||||
services:
|
||||
qdrant:
|
||||
image: qdrant/qdrant:latest
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- qdrant_data:/qdrant/storage
|
||||
|
||||
ollama:
|
||||
image: ollama/ollama:latest
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ollama_data:/root/.ollama
|
||||
# Konstante ~2 Cores statt Peaks über alle Host-Cores. Bewusster
|
||||
# Trade-off: langsamerer Ingest, dafür predictable Last.
|
||||
cpus: "2.0"
|
||||
environment:
|
||||
OLLAMA_NUM_PARALLEL: "1"
|
||||
healthcheck:
|
||||
test: ["CMD", "ollama", "list"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
|
||||
# One-shot: zieht das Embed-Modell in das ollama-Volume und beendet sich.
|
||||
# Idempotent — bei Redeploy mit warmem Volume nur Digest-Verifikation.
|
||||
# Verhindert den Startup-Crash des Ingestors bei fehlendem Modell.
|
||||
ollama-pull:
|
||||
image: ollama/ollama:latest
|
||||
restart: "no"
|
||||
depends_on:
|
||||
ollama:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
OLLAMA_HOST: "http://ollama:11434"
|
||||
command: ["pull", "qwen3-embedding:0.6b"]
|
||||
|
||||
ingestor:
|
||||
image: gitea.jeanlucmakiola.de/makiolaj/rag-ingestor:latest
|
||||
restart: unless-stopped
|
||||
pull_policy: always
|
||||
depends_on:
|
||||
qdrant:
|
||||
condition: service_started
|
||||
ollama:
|
||||
condition: service_healthy
|
||||
ollama-pull:
|
||||
condition: service_completed_successfully
|
||||
environment:
|
||||
# Coolify-Magie: macht den Ingestor öffentlich über den Coolify-Proxy
|
||||
# (inkl. Let's-Encrypt-TLS). Coolify generiert eine Domain; in der UI
|
||||
# auf die echte (z.B. ingest.jeanlucmakiola.de) überschreiben. Nextcloud
|
||||
# ruft nur diese öffentliche URL an — kein Coolify-Netz-Zugriff nötig.
|
||||
SERVICE_FQDN_INGESTOR_8000: /
|
||||
NEXTCLOUD_WEBDAV_URL: ${NEXTCLOUD_WEBDAV_URL}
|
||||
NEXTCLOUD_USER: ${NEXTCLOUD_USER}
|
||||
NEXTCLOUD_APP_PASSWORD: ${NEXTCLOUD_APP_PASSWORD}
|
||||
OLLAMA_URL: http://ollama:11434
|
||||
OLLAMA_EMBED_MODEL: qwen3-embedding:0.6b
|
||||
QDRANT_URL: http://qdrant:6333
|
||||
QDRANT_COLLECTION: ${QDRANT_COLLECTION:-rag_thb_studium}
|
||||
WEBHOOK_SECRET: ${WEBHOOK_SECRET}
|
||||
INGEST_ROOT: ${INGEST_ROOT:-Documents/THB}
|
||||
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||
# Extra-Label: definiert eine Traefik-Rate-Limit-Middleware (30 req/min,
|
||||
# Burst 15) gegen Spam des öffentlichen Webhooks. Das Binding an den
|
||||
# Coolify-Router (env-spezifische UUID) erfolgt in der Coolify-UI, s. README.
|
||||
labels:
|
||||
- "traefik.http.middlewares.rag-ratelimit.ratelimit.average=30"
|
||||
- "traefik.http.middlewares.rag-ratelimit.ratelimit.period=1m"
|
||||
- "traefik.http.middlewares.rag-ratelimit.ratelimit.burst=15"
|
||||
expose:
|
||||
- "8000"
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- python
|
||||
- -c
|
||||
- "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8000/health').status==200 else 1)"
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# MCP-Server für MetaMCP. Gleiches Image wie der ingestor (teilt den
|
||||
# Embed-Pfad → identische Vektoren wie beim Ingest), nur anderer command.
|
||||
# KEIN SERVICE_FQDN / kein expose → nicht public. Erreichbar ausschließlich
|
||||
# von MetaMCP über das externe Netz metamcp-net. Bearer-Token als zweite
|
||||
# Kontrolle hinter der Netz-Isolation.
|
||||
rag-mcp:
|
||||
image: gitea.jeanlucmakiola.de/makiolaj/rag-ingestor:latest
|
||||
restart: unless-stopped
|
||||
pull_policy: always
|
||||
command: ["python", "-m", "app.mcp_server"]
|
||||
depends_on:
|
||||
qdrant:
|
||||
condition: service_started
|
||||
ollama:
|
||||
condition: service_healthy
|
||||
ollama-pull:
|
||||
condition: service_completed_successfully
|
||||
environment:
|
||||
OLLAMA_URL: http://ollama:11434
|
||||
OLLAMA_EMBED_MODEL: qwen3-embedding:0.6b
|
||||
QDRANT_URL: http://qdrant:6333
|
||||
QDRANT_COLLECTION: ${QDRANT_COLLECTION:-rag_thb_studium}
|
||||
RAG_MCP_TOKEN: ${RAG_MCP_TOKEN}
|
||||
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||
# Vom MCP-Server ungenutzt, aber das geteilte Settings-Modell verlangt
|
||||
# diese Felder — harmlose Platzhalter, damit die Validierung durchläuft.
|
||||
NEXTCLOUD_WEBDAV_URL: http://unused
|
||||
NEXTCLOUD_USER: unused
|
||||
NEXTCLOUD_APP_PASSWORD: unused
|
||||
WEBHOOK_SECRET: unused
|
||||
networks:
|
||||
- default
|
||||
- metamcp-net
|
||||
|
||||
volumes:
|
||||
qdrant_data:
|
||||
ollama_data:
|
||||
|
||||
networks:
|
||||
# Coolify injiziert sein Stack-Netz als "default" automatisch. metamcp-net
|
||||
# ist ein eigens angelegtes externes Netz, dem nur rag-mcp und MetaMCP
|
||||
# beitreten — qdrant/ollama/ingestor bleiben außen vor.
|
||||
metamcp-net:
|
||||
external: true
|
||||
name: metamcp-net
|
||||
39
docker-compose.yml
Normal file
39
docker-compose.yml
Normal file
@@ -0,0 +1,39 @@
|
||||
# Local development only.
|
||||
# Production deployment goes via Coolify using docker/Dockerfile alone;
|
||||
# the compose file here is for booting up qdrant + ollama next to the
|
||||
# ingestor on a developer machine.
|
||||
services:
|
||||
ingestor:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/Dockerfile
|
||||
env_file: .env
|
||||
ports:
|
||||
- "8000:8000"
|
||||
depends_on:
|
||||
- qdrant
|
||||
- ollama
|
||||
|
||||
qdrant:
|
||||
image: qdrant/qdrant:latest
|
||||
ports:
|
||||
- "6333:6333"
|
||||
volumes:
|
||||
- qdrant_data:/qdrant/storage
|
||||
|
||||
ollama:
|
||||
image: ollama/ollama:latest
|
||||
ports:
|
||||
- "11434:11434"
|
||||
volumes:
|
||||
- ollama_data:/root/.ollama
|
||||
# Cap CPU so embedding peaks don't starve the host. Mirror these
|
||||
# limits in the production Coolify config — Ollama otherwise scales
|
||||
# inference threads to all available cores.
|
||||
cpus: "2.0"
|
||||
environment:
|
||||
OLLAMA_NUM_PARALLEL: "1"
|
||||
|
||||
volumes:
|
||||
qdrant_data:
|
||||
ollama_data:
|
||||
24
docker/Dockerfile
Normal file
24
docker/Dockerfile
Normal file
@@ -0,0 +1,24 @@
|
||||
FROM python:3.12-slim AS builder
|
||||
|
||||
RUN pip install --no-cache-dir uv
|
||||
|
||||
WORKDIR /app
|
||||
COPY pyproject.toml uv.lock* ./
|
||||
RUN uv sync --frozen --no-dev --no-install-project || uv sync --no-dev --no-install-project
|
||||
|
||||
COPY app ./app
|
||||
RUN uv sync --frozen --no-dev || uv sync --no-dev
|
||||
|
||||
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/.venv /app/.venv
|
||||
COPY --from=builder /app/app /app/app
|
||||
|
||||
ENV PATH="/app/.venv/bin:$PATH"
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -13,6 +13,7 @@ dependencies = [
|
||||
"python-docx>=1.1",
|
||||
"ollama>=0.4",
|
||||
"qdrant-client>=1.12",
|
||||
"mcp>=1.9",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
|
||||
@@ -3,6 +3,19 @@ import pytest
|
||||
import fitz # pymupdf
|
||||
from docx import Document
|
||||
|
||||
from app.config import Settings
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _ignore_dotenv():
|
||||
"""Tests must be deterministic regardless of a developer .env in the repo
|
||||
root. Settings reads env_file='.env'; neutralise it so tests see only the
|
||||
environment they explicitly set (e.g. via monkeypatch)."""
|
||||
original = Settings.model_config.get("env_file")
|
||||
Settings.model_config["env_file"] = None
|
||||
yield
|
||||
Settings.model_config["env_file"] = original
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_pdf_bytes() -> bytes:
|
||||
|
||||
82
tests/test_bulk.py
Normal file
82
tests/test_bulk.py
Normal file
@@ -0,0 +1,82 @@
|
||||
from unittest.mock import AsyncMock, call
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.webhook.models import EventType
|
||||
|
||||
|
||||
def _make_app(monkeypatch):
|
||||
monkeypatch.setenv("NEXTCLOUD_WEBDAV_URL", "http://nc")
|
||||
monkeypatch.setenv("NEXTCLOUD_USER", "u")
|
||||
monkeypatch.setenv("NEXTCLOUD_APP_PASSWORD", "p")
|
||||
monkeypatch.setenv("OLLAMA_URL", "http://ollama")
|
||||
monkeypatch.setenv("OLLAMA_EMBED_MODEL", "m")
|
||||
monkeypatch.setenv("QDRANT_URL", "http://qdrant")
|
||||
monkeypatch.setenv("QDRANT_COLLECTION", "rag_test")
|
||||
monkeypatch.setenv("WEBHOOK_SECRET", "abc")
|
||||
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
import app.ingest.pipeline as pipe
|
||||
pipe._qdrant_client.cache_clear()
|
||||
|
||||
monkeypatch.setattr("app.main._startup_ensure_collection", AsyncMock())
|
||||
from app.main import app
|
||||
return app
|
||||
|
||||
|
||||
def test_bulk_import_lists_and_dispatches(monkeypatch):
|
||||
app = _make_app(monkeypatch)
|
||||
|
||||
listed = [
|
||||
"Documents/THB/Studium/2.Semester/Databases/a.pdf",
|
||||
"Documents/THB/Studium/2.Semester/Databases/b.docx",
|
||||
"Documents/THB/Studium/2.Semester/Databases/.rag-meta.json", # ignored
|
||||
]
|
||||
monkeypatch.setattr("app.bulk.list_files_recursive", AsyncMock(return_value=listed))
|
||||
|
||||
process_mock = AsyncMock()
|
||||
monkeypatch.setattr("app.bulk.process_file", process_mock)
|
||||
|
||||
with TestClient(app) as client:
|
||||
r = client.post(
|
||||
"/bulk-import",
|
||||
json={"path": "Documents/THB/Studium/2.Semester/Databases"},
|
||||
headers={"X-Webhook-Secret": "abc"},
|
||||
)
|
||||
|
||||
assert r.status_code == 202
|
||||
body = r.json()
|
||||
assert body["dispatched"] == 2 # only .pdf and .docx, not the json sidecar
|
||||
process_mock.assert_has_calls(
|
||||
[
|
||||
call("Documents/THB/Studium/2.Semester/Databases/a.pdf", EventType.CREATED),
|
||||
call("Documents/THB/Studium/2.Semester/Databases/b.docx", EventType.CREATED),
|
||||
]
|
||||
)
|
||||
assert process_mock.await_count == 2
|
||||
|
||||
|
||||
def test_bulk_import_rejects_wrong_secret(monkeypatch):
|
||||
app = _make_app(monkeypatch)
|
||||
with TestClient(app) as client:
|
||||
r = client.post("/bulk-import", json={"path": "x"}, headers={"X-Webhook-Secret": "nope"})
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_bulk_import_rejects_while_in_progress(monkeypatch):
|
||||
app = _make_app(monkeypatch)
|
||||
# Simulate an in-flight bulk: the amplification guard must reject.
|
||||
monkeypatch.setattr("app.bulk._inflight", 4)
|
||||
|
||||
list_mock = AsyncMock()
|
||||
monkeypatch.setattr("app.bulk.list_files_recursive", list_mock)
|
||||
|
||||
with TestClient(app) as client:
|
||||
r = client.post(
|
||||
"/bulk-import", json={"path": "x"}, headers={"X-Webhook-Secret": "abc"}
|
||||
)
|
||||
|
||||
assert r.status_code == 409
|
||||
# Guard fires before any WebDAV listing happens.
|
||||
list_mock.assert_not_awaited()
|
||||
@@ -1,4 +1,4 @@
|
||||
from app.ingest.chunker import chunk_text, Chunk
|
||||
from app.ingest.chunker import chunk_text
|
||||
|
||||
|
||||
def test_chunk_short_text_single_chunk():
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
import pytest
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import pytest
|
||||
from app.ingest.extractors import extract, ExtractedPage, UnsupportedFileType
|
||||
from app.ingest.extractors import extract, UnsupportedFileType
|
||||
|
||||
|
||||
def test_extract_pdf_returns_pages(sample_pdf_bytes):
|
||||
|
||||
131
tests/test_mcp_server.py
Normal file
131
tests/test_mcp_server.py
Normal file
@@ -0,0 +1,131 @@
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _env(monkeypatch, *, token="tok"):
|
||||
monkeypatch.setenv("NEXTCLOUD_WEBDAV_URL", "http://nc")
|
||||
monkeypatch.setenv("NEXTCLOUD_USER", "u")
|
||||
monkeypatch.setenv("NEXTCLOUD_APP_PASSWORD", "p")
|
||||
monkeypatch.setenv("OLLAMA_URL", "http://ollama")
|
||||
monkeypatch.setenv("OLLAMA_EMBED_MODEL", "qwen3-embedding:0.6b")
|
||||
monkeypatch.setenv("QDRANT_URL", "http://qdrant")
|
||||
monkeypatch.setenv("QDRANT_COLLECTION", "rag_test")
|
||||
monkeypatch.setenv("WEBHOOK_SECRET", "s")
|
||||
if token is not None:
|
||||
monkeypatch.setenv("RAG_MCP_TOKEN", token)
|
||||
from app.config import get_settings
|
||||
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
async def _drain(app, scope):
|
||||
sent = []
|
||||
|
||||
async def receive():
|
||||
return {"type": "http.request", "body": b"", "more_body": False}
|
||||
|
||||
async def send(msg):
|
||||
sent.append(msg)
|
||||
|
||||
await app(scope, receive, send)
|
||||
return sent
|
||||
|
||||
|
||||
def _http_scope(auth_header: bytes | None):
|
||||
headers = [(b"authorization", auth_header)] if auth_header is not None else []
|
||||
return {"type": "http", "headers": headers}
|
||||
|
||||
|
||||
async def test_middleware_rejects_missing_token(monkeypatch):
|
||||
from app.mcp_server import BearerAuthMiddleware
|
||||
|
||||
inner = AsyncMock()
|
||||
mw = BearerAuthMiddleware(inner, "secret")
|
||||
|
||||
sent = await _drain(mw, _http_scope(None))
|
||||
|
||||
assert sent[0]["status"] == 401
|
||||
inner.assert_not_awaited()
|
||||
|
||||
|
||||
async def test_middleware_rejects_wrong_token(monkeypatch):
|
||||
from app.mcp_server import BearerAuthMiddleware
|
||||
|
||||
inner = AsyncMock()
|
||||
mw = BearerAuthMiddleware(inner, "secret")
|
||||
|
||||
sent = await _drain(mw, _http_scope(b"Bearer nope"))
|
||||
|
||||
assert sent[0]["status"] == 401
|
||||
inner.assert_not_awaited()
|
||||
|
||||
|
||||
async def test_middleware_passes_correct_token():
|
||||
from app.mcp_server import BearerAuthMiddleware
|
||||
|
||||
inner = AsyncMock()
|
||||
mw = BearerAuthMiddleware(inner, "secret")
|
||||
|
||||
await _drain(mw, _http_scope(b"Bearer secret"))
|
||||
|
||||
inner.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_middleware_passes_through_non_http_scope():
|
||||
from app.mcp_server import BearerAuthMiddleware
|
||||
|
||||
inner = AsyncMock()
|
||||
mw = BearerAuthMiddleware(inner, "secret")
|
||||
|
||||
await _drain(mw, {"type": "lifespan"})
|
||||
|
||||
inner.assert_awaited_once()
|
||||
|
||||
|
||||
def test_build_app_exits_without_token(monkeypatch):
|
||||
_env(monkeypatch, token=None)
|
||||
import app.mcp_server as m
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
m.build_app()
|
||||
|
||||
|
||||
async def test_rag_search_embeds_query_and_forwards_filters(monkeypatch):
|
||||
_env(monkeypatch)
|
||||
import app.mcp_server as m
|
||||
|
||||
embed = AsyncMock(return_value=[[0.5] * 4])
|
||||
search = MagicMock(return_value=[{"text": "hit", "score": 0.9}])
|
||||
monkeypatch.setattr(m, "embed_texts", embed)
|
||||
monkeypatch.setattr(m, "search_chunks", search)
|
||||
monkeypatch.setattr(m, "_qdrant", lambda: "CLIENT")
|
||||
|
||||
out = await m.rag_search("was ist BPMN?", limit=3, semester="2.Semester")
|
||||
|
||||
embed.assert_awaited_once_with(["was ist BPMN?"], model="qwen3-embedding:0.6b")
|
||||
args, kwargs = search.call_args
|
||||
assert args[0] == "CLIENT"
|
||||
assert args[1] == "rag_test"
|
||||
assert args[2] == [0.5] * 4
|
||||
assert kwargs == {
|
||||
"limit": 3,
|
||||
"semester": "2.Semester",
|
||||
"fach": None,
|
||||
"typ": None,
|
||||
}
|
||||
assert out == [{"text": "hit", "score": 0.9}]
|
||||
|
||||
|
||||
async def test_get_file_chunks_delegates(monkeypatch):
|
||||
_env(monkeypatch)
|
||||
import app.mcp_server as m
|
||||
|
||||
getter = MagicMock(return_value=[{"chunk_index": 0, "text": "x"}])
|
||||
monkeypatch.setattr(m, "get_chunks_by_path", getter)
|
||||
monkeypatch.setattr(m, "_qdrant", lambda: "CLIENT")
|
||||
|
||||
out = await m.get_file_chunks("Documents/x.pdf")
|
||||
|
||||
getter.assert_called_once_with("CLIENT", "rag_test", "Documents/x.pdf")
|
||||
assert out == [{"chunk_index": 0, "text": "x"}]
|
||||
@@ -5,6 +5,9 @@ from app.qdrant_store import (
|
||||
ensure_collection,
|
||||
upsert_chunks,
|
||||
delete_by_path,
|
||||
search_chunks,
|
||||
get_chunks_by_path,
|
||||
_payload_filter,
|
||||
ChunkPoint,
|
||||
)
|
||||
|
||||
@@ -79,3 +82,86 @@ def test_delete_by_path_uses_filter():
|
||||
selector = kwargs["points_selector"]
|
||||
# Inspect the FilterSelector → Filter → must → FieldCondition
|
||||
assert selector.filter.must[0].key == "file_path"
|
||||
|
||||
|
||||
def test_payload_filter_none_when_no_constraints():
|
||||
assert _payload_filter(None, None, None) is None
|
||||
|
||||
|
||||
def test_payload_filter_builds_only_given_conditions():
|
||||
flt = _payload_filter(semester="2.Semester", fach=None, typ="Vorlesungen")
|
||||
keys = [c.key for c in flt.must]
|
||||
assert keys == ["semester", "typ"]
|
||||
assert flt.must[0].match.value == "2.Semester"
|
||||
assert flt.must[1].match.value == "Vorlesungen"
|
||||
|
||||
|
||||
def test_search_chunks_maps_payload_and_score():
|
||||
hit = MagicMock()
|
||||
hit.payload = {
|
||||
"text": "chunk text",
|
||||
"file_path": "Documents/THB/2.Semester/Databases/a.pdf",
|
||||
"file_name": "a.pdf",
|
||||
"semester": "2.Semester",
|
||||
"fach": "Databases",
|
||||
"typ": "Vorlesungen",
|
||||
"page": 3,
|
||||
"chunk_index": 2,
|
||||
"ignored": "not in result fields",
|
||||
}
|
||||
hit.score = 0.87
|
||||
response = MagicMock()
|
||||
response.points = [hit]
|
||||
fake_client = MagicMock()
|
||||
fake_client.query_points.return_value = response
|
||||
|
||||
out = search_chunks(
|
||||
fake_client, "rag_test", [0.1] * 4, limit=5, fach="Databases"
|
||||
)
|
||||
|
||||
kwargs = fake_client.query_points.call_args.kwargs
|
||||
assert kwargs["collection_name"] == "rag_test"
|
||||
assert kwargs["limit"] == 5
|
||||
assert kwargs["query_filter"].must[0].key == "fach"
|
||||
assert out == [
|
||||
{
|
||||
"text": "chunk text",
|
||||
"file_path": "Documents/THB/2.Semester/Databases/a.pdf",
|
||||
"file_name": "a.pdf",
|
||||
"semester": "2.Semester",
|
||||
"fach": "Databases",
|
||||
"typ": "Vorlesungen",
|
||||
"page": 3,
|
||||
"chunk_index": 2,
|
||||
"score": 0.87,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_search_chunks_no_filter_passes_none():
|
||||
response = MagicMock()
|
||||
response.points = []
|
||||
fake_client = MagicMock()
|
||||
fake_client.query_points.return_value = response
|
||||
|
||||
search_chunks(fake_client, "rag_test", [0.1] * 4, limit=3)
|
||||
|
||||
assert fake_client.query_points.call_args.kwargs["query_filter"] is None
|
||||
|
||||
|
||||
def test_get_chunks_by_path_sorts_by_chunk_index():
|
||||
def pt(idx, page, text):
|
||||
m = MagicMock()
|
||||
m.payload = {"chunk_index": idx, "page": page, "text": text}
|
||||
return m
|
||||
|
||||
fake_client = MagicMock()
|
||||
fake_client.scroll.return_value = ([pt(2, 1, "c"), pt(0, 1, "a"), pt(1, 1, "b")], None)
|
||||
|
||||
rows = get_chunks_by_path(fake_client, "rag_test", "Documents/x.pdf")
|
||||
|
||||
assert [r["chunk_index"] for r in rows] == [0, 1, 2]
|
||||
assert [r["text"] for r in rows] == ["a", "b", "c"]
|
||||
scroll_kwargs = fake_client.scroll.call_args.kwargs
|
||||
assert scroll_kwargs["scroll_filter"].must[0].key == "file_path"
|
||||
assert scroll_kwargs["with_vectors"] is False
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.webhook.models import NextcloudEvent, EventType
|
||||
@@ -38,3 +41,81 @@ def test_verify_secret_missing_fail():
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
verify_secret(provided=None, expected="abc")
|
||||
assert exc_info.value.status_code == 401
|
||||
|
||||
|
||||
def _make_app(monkeypatch):
|
||||
"""Build the FastAPI app with all external clients stubbed."""
|
||||
monkeypatch.setenv("NEXTCLOUD_WEBDAV_URL", "http://nc")
|
||||
monkeypatch.setenv("NEXTCLOUD_USER", "u")
|
||||
monkeypatch.setenv("NEXTCLOUD_APP_PASSWORD", "p")
|
||||
monkeypatch.setenv("OLLAMA_URL", "http://ollama")
|
||||
monkeypatch.setenv("OLLAMA_EMBED_MODEL", "m")
|
||||
monkeypatch.setenv("QDRANT_URL", "http://qdrant")
|
||||
monkeypatch.setenv("QDRANT_COLLECTION", "rag_test")
|
||||
monkeypatch.setenv("WEBHOOK_SECRET", "abc")
|
||||
|
||||
# Reset cached settings/clients
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
import app.ingest.pipeline as pipe
|
||||
pipe._qdrant_client.cache_clear()
|
||||
|
||||
# Stub the lifespan startup so it doesn't try to talk to real services
|
||||
monkeypatch.setattr("app.main._startup_ensure_collection", AsyncMock())
|
||||
|
||||
from app.main import app
|
||||
return app
|
||||
|
||||
|
||||
def test_health_endpoint_no_auth(monkeypatch):
|
||||
app = _make_app(monkeypatch)
|
||||
with TestClient(app) as client:
|
||||
r = client.get("/health")
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"status": "ok"}
|
||||
|
||||
|
||||
def test_webhook_rejects_missing_secret(monkeypatch):
|
||||
app = _make_app(monkeypatch)
|
||||
with TestClient(app) as client:
|
||||
r = client.post("/webhook", json={
|
||||
"event_type": "created",
|
||||
"file_path": "a/b.pdf",
|
||||
"file_name": "b.pdf",
|
||||
})
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_webhook_rejects_wrong_secret(monkeypatch):
|
||||
app = _make_app(monkeypatch)
|
||||
with TestClient(app) as client:
|
||||
r = client.post(
|
||||
"/webhook",
|
||||
json={"event_type": "created", "file_path": "a/b.pdf", "file_name": "b.pdf"},
|
||||
headers={"X-Webhook-Secret": "wrong"},
|
||||
)
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_webhook_dispatches_background_task(monkeypatch):
|
||||
app = _make_app(monkeypatch)
|
||||
|
||||
process_mock = AsyncMock()
|
||||
monkeypatch.setattr("app.webhook.handler.process_file", process_mock)
|
||||
|
||||
with TestClient(app) as client:
|
||||
r = client.post(
|
||||
"/webhook",
|
||||
json={
|
||||
"event_type": "created",
|
||||
"file_path": "Documents/THB/Studium/2.Semester/Databases/x.pdf",
|
||||
"file_name": "x.pdf",
|
||||
},
|
||||
headers={"X-Webhook-Secret": "abc"},
|
||||
)
|
||||
|
||||
assert r.status_code == 202
|
||||
process_mock.assert_awaited_once_with(
|
||||
"Documents/THB/Studium/2.Semester/Databases/x.pdf",
|
||||
EventType.CREATED,
|
||||
)
|
||||
|
||||
322
uv.lock
generated
322
uv.lock
generated
@@ -38,6 +38,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "attrs"
|
||||
version = "26.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2026.4.22"
|
||||
@@ -47,6 +56,63 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cffi"
|
||||
version = "2.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.3.3"
|
||||
@@ -68,6 +134,59 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cryptography"
|
||||
version = "48.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9f/a9/db8f313fdcd85d767d4973515e1db101f9c71f95fced83233de224673757/cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920", size = 832984, upload-time = "2026-05-04T22:59:38.133Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/df/3d/01f6dd9190170a5a241e0e98c2d04be3664a9e6f5b9b872cde63aff1c3dd/cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6", size = 8001587, upload-time = "2026-05-04T22:57:36.803Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/6e/e90527eef33f309beb811cf7c982c3aeffcce8e3edb178baa4ca3ae4a6fa/cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c", size = 4690433, upload-time = "2026-05-04T22:57:40.373Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/04/673510ed51ddff56575f306cf1617d80411ee76831ccd3097599140efdfe/cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3", size = 4710620, upload-time = "2026-05-04T22:57:42.935Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/d5/e9c4ef932c8d800490c34d8bd589d64a31d5890e27ec9e9ad532be893294/cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5", size = 4696283, upload-time = "2026-05-04T22:57:45.294Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/29/174b9dfb60b12d59ecfc6cfa04bc88c21b42a54f01b8aae09bb6e51e4c7f/cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c", size = 5296573, upload-time = "2026-05-04T22:57:47.933Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/38/0d29a6fd7d0d1373f0c0c88a04ba20e359b257753ac497564cd660fc1d55/cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f", size = 4743677, upload-time = "2026-05-04T22:57:50.067Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/be/eef653013d5c63b6a490529e0316f9ac14a37602965d4903efed1399f32b/cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25", size = 4330808, upload-time = "2026-05-04T22:57:52.301Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/9e/500463e87abb7a0a0f9f256ec21123ecde0a7b5541a15e840ea54551fd81/cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602", size = 4695941, upload-time = "2026-05-04T22:57:54.603Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/dc/7303087450c2ec9e7fbb750e17c2abfbc658f23cbd0e54009509b7cc4091/cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c", size = 5252579, upload-time = "2026-05-04T22:57:57.207Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/c0/7101d3b7215edcdc90c45da544961fd8ed2d6448f77577460fa75a8443f7/cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5", size = 4743326, upload-time = "2026-05-04T22:57:59.535Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/d8/5b833bad13016f562ab9d063d68199a4bd121d18458e439515601d3357ec/cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321", size = 4826672, upload-time = "2026-05-04T22:58:01.996Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/e1/7074eb8bf3c135558c73fc2bcf0f5633f912e6fb87e868a55c454080ef09/cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74", size = 4972574, upload-time = "2026-05-04T22:58:03.968Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/70/e5a1b41d325f797f39427aa44ef8baf0be500065ab6d8e10369d850d4a4f/cryptography-48.0.0-cp311-abi3-win32.whl", hash = "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4", size = 3294868, upload-time = "2026-05-04T22:58:06.467Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/ac/8ac51b4a5fc5932eb7ee5c517ba7dc8cd834f0048962b6b352f00f41ebf9/cryptography-48.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7", size = 3817107, upload-time = "2026-05-04T22:58:08.845Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/84/70e3feea9feea87fd7cbe77efb2712ae1e3e6edf10749dc6e95f4e60e455/cryptography-48.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec", size = 7986556, upload-time = "2026-05-04T22:58:11.172Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/6e/18e07a618bb5442ba10cf4df16e99c071365528aa570dfcb8c02e25a303b/cryptography-48.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18", size = 4684776, upload-time = "2026-05-04T22:58:13.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/6a/4ea3b4c6c6759794d5ee2103c304a5076dc4b19ae1f9fe47dba439e159e9/cryptography-48.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20", size = 4698121, upload-time = "2026-05-04T22:58:16.448Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/59/6ff6ad6cae03bb887da2a5860b2c9805f8dac969ef01ce563336c49bd1d1/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff", size = 4690042, upload-time = "2026-05-04T22:58:18.544Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/b4/fc334ed8cfd705aca282fe4d8f5ae64a8e0f74932e9feecb344610cf6e4d/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c", size = 5282526, upload-time = "2026-05-04T22:58:20.75Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/08/9f8c5386cc4cd90d8255c7cdd0f5baf459a08502a09de30dc51f553d38dc/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db", size = 4733116, upload-time = "2026-05-04T22:58:23.627Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/77/99307d7574045699f8805aa500fa0fb83422d115b5400a064ddd306d7750/cryptography-48.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741", size = 4316030, upload-time = "2026-05-04T22:58:25.581Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/36/a608b98337af3cb2aff4818e406649d30572b7031918b04c87d979495348/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166", size = 4689640, upload-time = "2026-05-04T22:58:27.747Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/a6/825010a291b4438aecc1f568bc428189fc1175515223632477c07dc0a6df/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336", size = 5237657, upload-time = "2026-05-04T22:58:29.848Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/09/4e76a09b4caa29aad535ddc806f5d4c5d01885bd978bd984fbc6ca032cae/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057", size = 4732362, upload-time = "2026-05-04T22:58:32.009Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/78/444fa04a77d0cb95f417dda20d450e13c56ba8e5220fc892a1658f44f882/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae", size = 4819580, upload-time = "2026-05-04T22:58:34.254Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/85/ea67067c70a1fd4be2c63d35eeed82658023021affccc7b17705f8527dd2/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c", size = 4963283, upload-time = "2026-05-04T22:58:36.376Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/54/cc6d0f3deac3e81c7f847e8a189a12b6cdd65059b43dad25d4316abd849a/cryptography-48.0.0-cp314-cp314t-win32.whl", hash = "sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f", size = 3270954, upload-time = "2026-05-04T22:58:38.791Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/67/cc947e288c0758a4e5473d1dcb743037ab7785541265a969240b8885441a/cryptography-48.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12", size = 3797313, upload-time = "2026-05-04T22:58:40.746Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/63/61d4a4e1c6b6bab6ce1e213cd36a24c415d90e76d78c5eb8577c5541d2e8/cryptography-48.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86", size = 7983482, upload-time = "2026-05-04T22:58:43.769Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/ac/f5b5995b87770c693e2596559ffafe195b4033a57f14a82268a2842953f3/cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e", size = 4683266, upload-time = "2026-05-04T22:58:46.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/c6/8b14f67e18338fbc4adb76f66c001f5c3610b3e2d1837f268f47a347dbbb/cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f", size = 4696228, upload-time = "2026-05-04T22:58:48.22Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/73/f808fbae9514bd91b47875b003f13e284c8c6bdfd904b7944e803937eec1/cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7", size = 4689097, upload-time = "2026-05-04T22:58:50.9Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/01/d86632d7d28db8ae83221995752eeb6639ffb374c2d22955648cf8d52797/cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832", size = 5283582, upload-time = "2026-05-04T22:58:53.017Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/e1/50edc7a50334807cc4791fc4a0ce7468b4a1416d9138eab358bfc9a3d70b/cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c", size = 4730479, upload-time = "2026-05-04T22:58:55.611Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/af/99a582b1b1641ff5911ac559beb45097cf79efd4ead4657f578ef1af2d47/cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a", size = 4326481, upload-time = "2026-05-04T22:58:57.607Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/ee/89aa26a06ef0a7d7611788ffd571a7c50e368cc6a4d5eef8b4884e866edb/cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a", size = 4688713, upload-time = "2026-05-04T22:59:00.077Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/ba/bcb1b0bb7a33d4c7c0c4d4c7874b4a62ae4f56113a5f4baefa362dfb1f0f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a", size = 5238165, upload-time = "2026-05-04T22:59:02.317Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/70/ca4003b1ce5ca3dc3186ada51908c8a9b9ff7d5cab83cc0d43ee14ec144f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239", size = 4729947, upload-time = "2026-05-04T22:59:05.255Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/a0/4ec7cf774207905aef1a8d11c3750d5a1db805eb380ee4e16df317870128/cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c", size = 4822059, upload-time = "2026-05-04T22:59:07.802Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/75/a2e55f99c16fcac7b5d6c1eb19ad8e00799854d6be5ca845f9259eae1681/cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4", size = 4960575, upload-time = "2026-05-04T22:59:09.851Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/23/6e6f32143ab5d8b36ca848a502c4bcd477ae75b9e1677e3530d669062578/cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd", size = 3279117, upload-time = "2026-05-04T22:59:12.019Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastapi"
|
||||
version = "0.136.1"
|
||||
@@ -218,6 +337,15 @@ http2 = [
|
||||
{ name = "h2" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpx-sse"
|
||||
version = "0.4.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyperframe"
|
||||
version = "6.1.0"
|
||||
@@ -245,6 +373,33 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonschema"
|
||||
version = "4.26.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "attrs" },
|
||||
{ name = "jsonschema-specifications" },
|
||||
{ name = "referencing" },
|
||||
{ name = "rpds-py" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonschema-specifications"
|
||||
version = "2025.9.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "referencing" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lxml"
|
||||
version = "6.1.0"
|
||||
@@ -325,6 +480,31 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/a0/9b916c68c0e57752c07f8f64b30138d9d4059dbeb27b90274dedbea128ff/lxml-6.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:26dd9f57ee3bd41e7d35b4c98a2ffd89ed11591649f421f0ec19f67d50ec67ac", size = 3817120, upload-time = "2026-04-18T04:32:15.803Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mcp"
|
||||
version = "1.27.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "httpx" },
|
||||
{ name = "httpx-sse" },
|
||||
{ name = "jsonschema" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "pyjwt", extra = ["crypto"] },
|
||||
{ name = "python-multipart" },
|
||||
{ name = "pywin32", marker = "sys_platform == 'win32'" },
|
||||
{ name = "sse-starlette" },
|
||||
{ name = "starlette" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/38/83/d1efe7c2980d8a3afa476f4e3d42d53dd54c0ab94c27bee5d755b45c8b73/mcp-1.27.1.tar.gz", hash = "sha256:0f47e1820f8f8f941466b39749eb1d1839a04caddca2bc60e9d46e8a99914924", size = 608458, upload-time = "2026-05-08T16:50:12.601Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/73/42d9596facebdb533b7f0b86c1b0364ef350d1f8ba78b1052e8a58b48b65/mcp-1.27.1-py3-none-any.whl", hash = "sha256:1af3c4203b329430fde7a87b4fcb6392a041f5cb851fd68fc674016ab4e7c06f", size = 216260, upload-time = "2026-05-08T16:50:10.547Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "numpy"
|
||||
version = "2.4.4"
|
||||
@@ -444,6 +624,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/88/95/608f665226bca68b736b79e457fded9a2a38c4f4379a4a7614303d9db3bc/protobuf-7.34.1-py3-none-any.whl", hash = "sha256:bb3812cd53aefea2b028ef42bd780f5b96407247f20c6ef7c679807e9d188f11", size = 170715, upload-time = "2026-03-20T17:34:45.384Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pycparser"
|
||||
version = "3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.13.3"
|
||||
@@ -557,6 +746,20 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyjwt"
|
||||
version = "2.12.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
crypto = [
|
||||
{ name = "cryptography" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pymupdf"
|
||||
version = "1.27.2.3"
|
||||
@@ -624,6 +827,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-multipart"
|
||||
version = "0.0.29"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4e/fe/70bd71a6738b09a0bdf6480ca6436b167469ca4578b2a0efbe390b4b0e70/python_multipart-0.0.29.tar.gz", hash = "sha256:643e93849196645e2dbdd81a0f8829a23123ad7f797a84a364c6fb3563f18904", size = 45678, upload-time = "2026-05-17T17:29:47.654Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/cb/769cfc37177252872a45a71f3fbdde9d51b471a3f3c14bfe95dde3407386/python_multipart-0.0.29-py3-none-any.whl", hash = "sha256:2ddcc971cef266225f54f552d8fa10bcfbb1f14446caec199060daac59ff2d69", size = 29640, upload-time = "2026-05-17T17:29:45.69Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pywin32"
|
||||
version = "311"
|
||||
@@ -711,6 +923,7 @@ source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "fastapi" },
|
||||
{ name = "httpx" },
|
||||
{ name = "mcp" },
|
||||
{ name = "ollama" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pydantic-settings" },
|
||||
@@ -731,6 +944,7 @@ dev = [
|
||||
requires-dist = [
|
||||
{ name = "fastapi", specifier = ">=0.115" },
|
||||
{ name = "httpx", specifier = ">=0.28" },
|
||||
{ name = "mcp", specifier = ">=1.9" },
|
||||
{ name = "ollama", specifier = ">=0.4" },
|
||||
{ name = "pydantic", specifier = ">=2.10" },
|
||||
{ name = "pydantic-settings", specifier = ">=2.7" },
|
||||
@@ -747,6 +961,20 @@ dev = [
|
||||
{ name = "respx", specifier = ">=0.22" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "referencing"
|
||||
version = "0.37.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "attrs" },
|
||||
{ name = "rpds-py" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "respx"
|
||||
version = "0.23.1"
|
||||
@@ -759,6 +987,100 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/4a/221da6ca167db45693d8d26c7dc79ccfc978a440251bf6721c9aaf251ac0/respx-0.23.1-py2.py3-none-any.whl", hash = "sha256:b18004b029935384bccfa6d7d9d74b4ec9af73a081cc28600fffc0447f4b8c1a", size = 25557, upload-time = "2026-04-08T14:37:14.613Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rpds-py"
|
||||
version = "0.30.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sse-starlette"
|
||||
version = "3.4.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "starlette" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f7/2b/58abc2d1fd397e7dde08e947e05c884d8ef2f78d5e2588c17a12d42d6994/sse_starlette-3.4.4.tar.gz", hash = "sha256:07e0fa0460138baf25cdd5fb28683472c3995dc1642225191b3832d62526bcb0", size = 31819, upload-time = "2026-05-12T17:37:17.019Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/67/805710444ea8cc75fbf70b920ed431a560c4bf9c57f7d5a3117213189399/sse_starlette-3.4.4-py3-none-any.whl", hash = "sha256:3f4dd50d8aed2771a091f3a83000323fc3844541c16b4fe585ae2420cc6df973", size = 16514, upload-time = "2026-05-12T17:37:15.601Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "starlette"
|
||||
version = "1.0.0"
|
||||
|
||||
Reference in New Issue
Block a user