From 3ac42645ef946d757c1f58aa565af03c0f4d0d6e Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 3 Oct 2026 10:47:48 +0200 Subject: [PATCH] Richte Develop-Build und Main-Deploy analog zur Jinkendo-Familie ein. Co-authored-by: Cursor --- .dockerignore | 16 + .env.example | 23 + .gitea/workflows/deploy-dev.yml | 58 ++ .gitea/workflows/deploy-prod.yml | 66 ++ .gitea/workflows/test.yml | 35 + .gitignore | 22 + .specs/buying-agent-v0.1-spec.md | 671 +++++++++++++++ Dockerfile | 42 + README.md | 41 +- alembic.ini | 38 + app/__init__.py | 1 + app/api/__init__.py | 0 app/api/health.py | 45 + app/main.py | 10 + app/persistence/__init__.py | 0 app/persistence/db.py | 18 + app/settings.py | 27 + docker-compose.dev-env.yml | 61 ++ docker-compose.test.yml | 45 + docker-compose.yml | 64 ++ docs/DEPLOYMENT.md | 87 ++ docs/buying-agent-v0.1-spec-v1.1.md | 671 +++++++++++++++ migrations/env.py | 38 + migrations/script.py.mako | 25 + migrations/versions/0001_deploy_skeleton.py | 21 + pyproject.toml | 32 + scripts/startup.sh | 7 + tests/unit/test_health_live.py | 11 + uv.lock | 875 ++++++++++++++++++++ 29 files changed, 3047 insertions(+), 3 deletions(-) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitea/workflows/deploy-dev.yml create mode 100644 .gitea/workflows/deploy-prod.yml create mode 100644 .gitea/workflows/test.yml create mode 100644 .gitignore create mode 100644 .specs/buying-agent-v0.1-spec.md create mode 100644 Dockerfile create mode 100644 alembic.ini create mode 100644 app/__init__.py create mode 100644 app/api/__init__.py create mode 100644 app/api/health.py create mode 100644 app/main.py create mode 100644 app/persistence/__init__.py create mode 100644 app/persistence/db.py create mode 100644 app/settings.py create mode 100644 docker-compose.dev-env.yml create mode 100644 docker-compose.test.yml create mode 100644 docker-compose.yml create mode 100644 docs/DEPLOYMENT.md create mode 100644 docs/buying-agent-v0.1-spec-v1.1.md create mode 100644 migrations/env.py create mode 100644 migrations/script.py.mako create mode 100644 migrations/versions/0001_deploy_skeleton.py create mode 100644 pyproject.toml create mode 100644 scripts/startup.sh create mode 100644 tests/unit/test_health_live.py create mode 100644 uv.lock diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1e66192 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +.git +.gitea +.specs +docs +.env +.env.* +!.env.example +.venv +.pytest_cache +.mypy_cache +.ruff_cache +__pycache__ +*.pyc +*.pyo +*.md +!README.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..448f3c9 --- /dev/null +++ b/.env.example @@ -0,0 +1,23 @@ +# Copy to .env next to docker-compose*.yml. Never commit .env. +# Dev server: /home/lars/docker/buyingagent-dev/.env +# Prod server: /home/lars/docker/buyingagent/.env + +# ─── local / production (docker-compose.yml) ──────────────────────────────── +DB_NAME=buyingagent +DB_USER=buyingagent +DB_PASSWORD=change_me +ENVIRONMENT=production +ENGINE_BUILD=dev +BUYING_AGENT_API_BIND=127.0.0.1 +BUYING_AGENT_API_PORT=8000 + +# Production on the Raspberry Pi (192.168.2.49): +# BUYING_AGENT_API_BIND=0.0.0.0 +# BUYING_AGENT_API_PORT=8006 + +# ─── development server (docker-compose.dev-env.yml) ──────────────────────── +# DB_NAME=buyingagent_dev +# DB_USER=buyingagent_dev +# DB_PASSWORD=dev_password_change_me +# ENVIRONMENT=development +# BUYING_AGENT_API_PORT=8095 diff --git a/.gitea/workflows/deploy-dev.yml b/.gitea/workflows/deploy-dev.yml new file mode 100644 index 0000000..6856893 --- /dev/null +++ b/.gitea/workflows/deploy-dev.yml @@ -0,0 +1,58 @@ +name: Deploy Development + +on: + push: + branches: [develop] + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Deploy BuyingAgent to development + run: | + set -e + echo "=== Deploying BuyingAgent to DEVELOPMENT ===" + REPO="http://192.168.2.144:3000/Lars/BuyingAgent.git" + TARGET="/home/lars/docker/buyingagent-dev" + mkdir -p "$TARGET" + cd "$TARGET" + if [ ! -d .git ]; then + git clone -b develop "$REPO" . + else + git fetch origin develop + git checkout develop + git reset --hard origin/develop + fi + if [ ! -f .env ]; then + cp .env.example .env + echo "created .env from .env.example" + fi + if grep -q '^BUYING_AGENT_API_PORT=' .env; then + sed -i 's/^BUYING_AGENT_API_PORT=.*/BUYING_AGENT_API_PORT=8095/' .env + else + echo 'BUYING_AGENT_API_PORT=8095' >> .env + fi + export ENGINE_BUILD BUYING_AGENT_API_PORT + ENGINE_BUILD="$(git rev-parse --short HEAD)" + BUYING_AGENT_API_PORT=8095 + docker compose -f docker-compose.dev-env.yml build --no-cache api + if ! ENGINE_BUILD="$ENGINE_BUILD" BUYING_AGENT_API_PORT="$BUYING_AGENT_API_PORT" \ + docker compose -f docker-compose.dev-env.yml up -d --wait; then + echo "compose up --wait failed — api logs:" + docker compose -f docker-compose.dev-env.yml logs api --tail 150 || true + docker compose -f docker-compose.dev-env.yml ps || true + exit 1 + fi + if ! curl -sf http://localhost:8095/health/live; then + echo "DEV /health/live not reachable — api logs:" + docker compose -f docker-compose.dev-env.yml logs api --tail 150 || true + exit 1 + fi + echo "DEV /health/live OK" + if ! curl -sf http://localhost:8095/health/ready; then + echo "DEV /health/ready failed — api logs:" + docker compose -f docker-compose.dev-env.yml logs api --tail 150 || true + exit 1 + fi + echo "DEV /health/ready OK" + echo "=== BuyingAgent DEV deploy complete (${ENGINE_BUILD}) ===" diff --git a/.gitea/workflows/deploy-prod.yml b/.gitea/workflows/deploy-prod.yml new file mode 100644 index 0000000..5609f62 --- /dev/null +++ b/.gitea/workflows/deploy-prod.yml @@ -0,0 +1,66 @@ +name: Deploy Production + +on: + push: + branches: [main] + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Deploy BuyingAgent to production + run: | + set -e + echo "=== Deploying BuyingAgent to PRODUCTION ===" + REPO="http://192.168.2.144:3000/Lars/BuyingAgent.git" + TARGET="/home/lars/docker/buyingagent" + mkdir -p "$TARGET" + cd "$TARGET" + if [ ! -d .git ]; then + git clone -b main "$REPO" . + else + git fetch origin main + git checkout main + git reset --hard origin/main + fi + if [ ! -f .env ]; then + cp .env.example .env + echo "created .env from .env.example — set a unique DB_PASSWORD for production" + fi + if grep -q '^BUYING_AGENT_API_BIND=' .env; then + sed -i 's/^BUYING_AGENT_API_BIND=.*/BUYING_AGENT_API_BIND=0.0.0.0/' .env + else + echo 'BUYING_AGENT_API_BIND=0.0.0.0' >> .env + fi + if grep -q '^BUYING_AGENT_API_PORT=' .env; then + sed -i 's/^BUYING_AGENT_API_PORT=.*/BUYING_AGENT_API_PORT=8006/' .env + else + echo 'BUYING_AGENT_API_PORT=8006' >> .env + fi + export ENGINE_BUILD BUYING_AGENT_API_BIND BUYING_AGENT_API_PORT + ENGINE_BUILD="$(git rev-parse --short HEAD)" + BUYING_AGENT_API_BIND=0.0.0.0 + BUYING_AGENT_API_PORT=8006 + docker compose build --no-cache api + if ! ENGINE_BUILD="$ENGINE_BUILD" \ + BUYING_AGENT_API_BIND="$BUYING_AGENT_API_BIND" \ + BUYING_AGENT_API_PORT="$BUYING_AGENT_API_PORT" \ + docker compose up -d --wait; then + echo "compose up --wait failed — api logs:" + docker compose logs api --tail 150 || true + docker compose ps || true + exit 1 + fi + if ! curl -sf http://localhost:8006/health/live; then + echo "PROD /health/live not reachable — api logs:" + docker compose logs api --tail 150 || true + exit 1 + fi + echo "PROD /health/live OK" + if ! curl -sf http://localhost:8006/health/ready; then + echo "PROD /health/ready failed — api logs:" + docker compose logs api --tail 150 || true + exit 1 + fi + echo "PROD /health/ready OK" + echo "=== BuyingAgent PROD deploy complete (${ENGINE_BUILD}) ===" diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml new file mode 100644 index 0000000..3dc4f68 --- /dev/null +++ b/.gitea/workflows/test.yml @@ -0,0 +1,35 @@ +name: Test Suite + +on: + push: + branches: [develop] + workflow_run: + workflows: ["Deploy Development"] + types: [completed] + workflow_dispatch: + +jobs: + smoke-dev: + if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }} + runs-on: ubuntu-latest + steps: + - name: Non-destructive smoke against the running Dev API + run: | + set -e + echo "wait for live Dev API (buyingagent-dev)" + for i in $(seq 1 60); do + if curl -sf http://localhost:8095/health/live >/dev/null; then + echo "DEV /health/live ready (attempt $i)" + break + fi + if [ "$i" -eq 60 ]; then + echo "timeout waiting for DEV /health/live" + exit 1 + fi + sleep 5 + done + curl -sf http://localhost:8095/health/live + echo + curl -sf http://localhost:8095/health/ready + echo + echo "LAN smoke OK: http://192.168.2.49:8095" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a6bdecb --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +# Environment and secrets +.env +.env.* +!.env.example + +# Python +.venv/ +**/__pycache__/ +*.py[cod] +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +*.egg-info/ +dist/ +build/ + +# OS / editor +.DS_Store +Thumbs.db +*.swp +.idea/ +.vscode/* diff --git a/.specs/buying-agent-v0.1-spec.md b/.specs/buying-agent-v0.1-spec.md new file mode 100644 index 0000000..d470842 --- /dev/null +++ b/.specs/buying-agent-v0.1-spec.md @@ -0,0 +1,671 @@ +# Buying Agent v0.1 – Implementierungsspezifikation + +Status: implementierungsbereit · Spezifikationsversion: 1.1 · Stand: 2026-10-03 + +## Änderungen gegenüber Spezifikationsversion 1.0 + +- Preference-Scoring erhält ein explizites `strategy`-Feld; v0.1 implementiert ausschließlich `numeric_linear`, ohne vorsorgliche Plugin-Infrastruktur. +- Produkt-Eignung und Angebots-Eignung werden getrennt als `product_eligibility` und `offer_eligibility` bewertet; `overall_eligibility` wird daraus abgeleitet. +- Die Preisbewertung wird konzeptionell von der Identität eines Offers entkoppelt: v0.1 nutzt weiterhin nur die Historie des konkreten Offers, spätere Scoring-Versionen dürfen jedoch Variant- oder Market-Level-Referenzserien konsumieren. +- `raw_value` wird bei JSONB als inhaltlich unverändert, nicht als bytegenau identisch beschrieben. +- Ein verbindlicher `Future Extension Contract` dokumentiert, welche späteren Erweiterungen v0.1 nicht verbauen darf, ohne sie bereits zu implementieren. + +## 1. Auftrag und Zielbild + +Implementiere ein eigenständig lauffähiges Backend für einen generischen Buying-/Deal-Agenten. Das System bewertet, wie gut eine konkrete Produktvariante zu einem Bedarfsprofil passt und wie attraktiv ein bestimmtes Angebot preislich ist. Es liefert nachvollziehbare Entscheidungen, keine automatischen Käufe. + +Notebooks sind der erste Anwendungsfall, nicht das Datenmodell. Weitere Kategorien wie GPU-Server, Monitore oder Kameras müssen durch neue Attribute und Profile ohne Änderungen an der Engine oder zusätzliche produktspezifische DB-Spalten möglich sein. + +Diese Datei ist die vollständige Arbeitsgrundlage. Ein Zugriff auf den ursprünglichen Chat ist nicht erforderlich. Die dort beschlossene Architektur wird beibehalten. Die hier definierten Formeln, Fehlerfälle, API-Verträge und Zeitregeln konkretisieren bislang offene Punkte als verbindliche v0.1-Entscheidungen. Die Beispielwerte sind synthetische Testdaten und keine Markt- oder Herstellerangaben. + +### Walking Skeleton + +Der MVP muss diesen vollständigen Ablauf tatsächlich ausführen und dauerhaft speichern: + +```text +Profil → Produkt → Variante + Attribute → Angebot → Preisbeobachtungen + → Constraints → ProductScore → PriceScore → DealScore + → Klassifikation + Begründungen → gespeicherte Evaluation → API-Antwort +``` + +Ein manuell erfasster Kandidat genügt für den ersten vertikalen Durchstich. Danach werden Grenzfälle und drei Beispielkandidaten ergänzt. Keine bloßen Interfaces ohne funktionsfähigen End-to-End-Pfad. + +### Explizit nicht enthalten + +- Keepa, Idealo, Geizhals oder andere externe Datenquellen und Scraper. +- n8n, Scheduler, Benachrichtigungen und Hintergrundjobs. +- LLM-Normalisierung oder LLM-Bewertung. +- Web-UI; generierte OpenAPI-/Swagger-Dokumentation ist erlaubt. +- Auto-Discovery, automatische Produktsuche oder automatische Zuordnung fremder Produkte. +- Checkout, automatische Käufe, Nutzerkonten, Mehrmandantenfähigkeit und öffentliche Bereitstellung. +- Wechselkursumrechnung, marktweite Preisprognosen, ML und Optimierung von Gewichten. + +Diese Funktionen dürfen später über Adapter und Services ergänzt werden. Keine vorsorglichen Frameworks, leeren Integrationen oder Infrastruktur dafür bauen. + +## 2. Stack und Architekturgrenzen + +Verbindlich: Python 3.13, FastAPI, Pydantic 2, SQLAlchemy 2, Alembic, PostgreSQL 17, pytest und Docker Compose. Verwende einen synchronen SQLAlchemy-Zugriff mit psycopg 3; ein asynchroner Stack ist für v0.1 nicht erforderlich. Abhängigkeiten mit kompatiblen Versionen und einem eingecheckten Lockfile reproduzierbar festlegen. + +Abhängigkeiten zeigen nach innen: + +```text +HTTP/API → Application Services → Domain / Constraint Engine / Scoring + ↓ + Repositories und Source-Adapter + ↓ + PostgreSQL +``` + +- `domain` und `scoring` importieren weder FastAPI noch SQLAlchemy oder konkrete Quellen. +- Die Engine ist eine reine Berechnung über explizite Eingaben. Kein Netzwerkzugriff, kein DB-Zugriff und kein implizites Lesen der Systemzeit. +- Services laden Daten, erzeugen einen konsistenten Snapshot, rufen die Engine auf und speichern das Ergebnis atomar. +- API-Schemas und ORM-Modelle sind getrennt von Domain-Modellen. Kleine Dataclasses oder Pydantic-Modelle sind für die Domain zulässig. +- Regeln liegen als validierte Daten vor; keine Ausführung von Python, SQL, `eval` oder Templates aus Profilen. +- n8n bleibt später ein externer Aufrufer. Die gesamte Buying Engine funktioniert ohne n8n. + +## 3. Fachliche Entitäten und Identität + +### Product + +Produktfamilie bzw. Modell, beispielsweise „Example Mobile 13“. Felder: UUID `id`, `category`, `brand`, `name`, optional `model`, `created_at`. Keine RAM-, SSD- oder Preisfelder. Kategorie ist ein normalisierter String, kein Notebook-spezifisches Enum. + +### ProductVariant + +Eine eindeutig beschriebene technische Konfiguration eines Products. Felder: UUID `id`, `product_id`, `name`, optionale Hersteller-SKU `manufacturer_sku`, optionale Kennungen `identifiers` als JSON-Objekt, `created_at`. + +RAM, Speicher, Farbe, Tastaturlayout und Ausstattung gehören zur Variante. Angebote unterschiedlicher Konfigurationen dürfen nicht zusammengeführt werden. In v0.1 verknüpft der Aufrufer Varianten explizit; keine heuristische Identitätsauflösung. Hersteller-SKUs sind nicht global eindeutig. Identische Namen sind kein Dublettenbeweis. + +### Offer + +Ein konkretes Händlerangebot für genau eine Variante. Felder: UUID `id`, `variant_id`, `source_id`, `external_id`, `seller`, optionale `url`, `market` (z. B. `DE`), `currency` (dreistelliger Großbuchstaben-Code), `condition` (`new`, `refurbished`, `used`), `created_at`. + +Eindeutigkeit: `(source_id, external_id)`. Unterschiedliche Verkäufer, Zustände, Märkte oder Lieferumfänge sind unterschiedliche Angebote. Technisch abweichende Bundles erfordern eine passende Variante. Diese Identitätsfelder bleiben nach Erstellung unverändert. Ein Offer enthält keinen unabhängig veränderbaren „aktuellen Preis“. + +### PriceObservation + +Append-only-Beobachtung eines Offers zu einem Zeitpunkt. Felder: UUID `id`, `offer_id`, `source_id`, `external_observation_id`, `observed_at`, `ingested_at`, `item_price`, nullable `shipping_price`, `currency`, `availability` (`in_stock`, `out_of_stock`, `unknown`), `raw_payload` als JSON, `confidence`. + +- Geld als `Decimal`/PostgreSQL `NUMERIC(12,2)`, nie binäre Floats. API-Geldwerte werden als Dezimalstrings übertragen. +- `item_price > 0`; bekannte Versandkosten `>= 0`; null bedeutet unbekannt, nicht kostenlos. +- Gesamtpreis = Bruttopreis inklusive bekannter obligatorischer Bestandteile + Versand. Nur direkt für jeden Käufer verfügbare Preise erfassen; keine Mitgliederrabatte, Finanzierung oder personalisierte Coupons modellieren. +- Die Währung muss der Offer-Währung entsprechen. `source_id` muss der Offer-Quelle entsprechen. +- Zeitzonen sind obligatorisch; Speicherung und Ausgabe in UTC. Zeitpunkte in der Zukunft relativ zur Ingestion werden abgewiesen. +- Eindeutigkeit `(offer_id, external_observation_id)`: identischer Wiederholungsimport liefert den vorhandenen Datensatz, widersprüchlicher Inhalt HTTP 409. `ingested_at` ist serverseitig und zählt beim Inhaltsvergleich nicht mit. +- Beobachtungen werden nicht überschrieben. Korrekturen werden als neue Beobachtung mit neuer externer ID erfasst; ein administrativer Korrekturworkflow ist nicht Bestandteil von v0.1. + +## 4. Generische Attribute und Normalisierung + +Attribute werden für v0.1 ausschließlich an Varianten erfasst. Es gibt keine implizite Vererbung von Product-Attributen. Eine spätere Erweiterung muss eine eigene, explizite Vorrangregel definieren. + +### Attributdefinition + +`attribute_definitions` enthält `key`, `value_type` (`number`, `string`, `boolean`, `string_list`), nullable `canonical_unit`, optionale `minimum`/`maximum` und `description`. Schlüssel sind stabile `snake_case`-Bezeichner. Neue Definitionen lassen sich per API ohne Migration anlegen. Bestehende Definitionen sind in v0.1 unveränderlich; geänderte Semantik erhält einen neuen Schlüssel. + +Beispieldefinitionen: + +| key | type | canonical_unit | Wertebereich | +|---|---|---|---| +| ram_gb | number | GB | >= 0 | +| storage_gb | number | GB | >= 0 | +| weight_kg | number | kg | > 0, im Eingabevalidator | +| battery_score | number | null | 0–100 | +| display_score | number | null | 0–100 | +| cpu_score | number | null | 0–100 | +| ports | string_list | null | beliebige Strings | + +`*_score`-Attribute sind ausdrücklich manuelle Einschätzungen auf einer dokumentierten 0–100-Skala; die Engine erfindet keine Benchmarks oder Messwerte. + +### Attributwert + +Jede Zeile in `variant_attributes` ist eine unveränderliche Aussage einer Quelle: + +```json +{ + "attribute": "ram_gb", + "raw_value": "24GB LPDDR5X RAM", + "normalized_value": 24, + "unit": "GB", + "normalization_status": "normalized", + "normalizer_version": "manual-v1", + "confidence": 1.0, + "source_id": "manual", + "source_reference": "demo/notebook-a/ram", + "observed_at": "2026-10-03T10:00:00Z" +} +``` + +Zusätzlich serverseitig: UUID `id`, `variant_id`, `ingested_at`. `raw_value` ist verpflichtend und bleibt inhaltlich unverändert erhalten; es darf ein JSON-Skalar oder -Objekt sein. Bei Speicherung als JSONB ist keine bytegenaue Erhaltung der ursprünglichen JSON-Serialisierung garantiert. Falls eine spätere Quelle den exakten Response-Body benötigt, muss dieser separat als Rohtext gespeichert werden; das ist nicht Bestandteil von v0.1. `normalized_value` ist ein typisierter JSON-Wert. Bei `normalization_status=unknown` ist er null; ein optionales `normalization_error` erklärt den fehlenden Wert. Eine erfolglose Normalisierung ist kein Zahlenwert 0. + +Manuelle Erfassung liefert Raw- und Normalized-Wert explizit. `ManualNormalizer` validiert Typ, Bereich und kanonische Einheit, analysiert aber keinen Freitext. Falsche Typen oder Einheiten werden mit 422 abgewiesen. Keine automatische Umwandlung von `"32"` zu 32 oder `true` zu 1. `confidence` liegt zwischen 0 und 1 und bezeichnet die Zuverlässigkeit der Aussage, nicht den Produktnutzen. Eine absichtlich unbekannte Aussage ist zulässig. + +Bei mehreren Aussagen zu demselben Schlüssel verwendet der Snapshot die jüngste nach `(observed_at, ingested_at, id)` absteigend, jeweils nur bis zum Auswertungszeitpunkt bzw. Snapshot-Cutoff. Anschließend wird deren Confidence geprüft. Ein neuer unsicherer oder unbekannter Wert darf nicht stillschweigend durch einen älteren günstigen Wert ersetzt werden. Vorrang verschiedener Quellen ist damit zunächst bewusst einfach; spätere Auswahlstrategien benötigen eine neue Regelversion. + +## 5. Product Profile und Versionierung + +Ein Profil beschreibt Kategorie, Budget, harte Mindestanforderungen und gewichtete Präferenzen. Es besitzt eine stabile String-ID und eine positive Integer-Version. Jede `(id, version)` ist unveränderlich. Eine Änderung wird als neue Version gespeichert. Evaluationen müssen eine konkrete Version angeben; kein implizites „latest“. + +Vollständiges Beispiel für `profiles/mobile-development-notebook.json`: + +```json +{ + "id": "mobile-development-notebook", + "version": 1, + "name": "Mobiler Entwicklungsrechner", + "category": "notebook", + "currency": "EUR", + "market": "DE", + "allowed_conditions": ["new"], + "budget": {"target": "1500.00", "maximum": "2000.00"}, + "scoring_version": "generic-v1", + "constraints": [ + {"id": "min_ram", "attribute": "ram_gb", "operator": ">=", "value": 16, "mandatory": true}, + {"id": "min_storage", "attribute": "storage_gb", "operator": ">=", "value": 512, "mandatory": true}, + {"id": "max_weight", "attribute": "weight_kg", "operator": "<=", "value": 1.6, "mandatory": true} + ], + "preferences": [ + {"id": "ram", "attribute": "ram_gb", "strategy": "numeric_linear", "direction": "higher", "acceptable": 16, "ideal": 32, "weight": 0.30}, + {"id": "weight", "attribute": "weight_kg", "strategy": "numeric_linear", "direction": "lower", "acceptable": 1.6, "ideal": 1.0, "weight": 0.25}, + {"id": "battery", "attribute": "battery_score", "strategy": "numeric_linear", "direction": "higher", "acceptable": 60, "ideal": 100, "weight": 0.20}, + {"id": "display", "attribute": "display_score", "strategy": "numeric_linear", "direction": "higher", "acceptable": 60, "ideal": 100, "weight": 0.15}, + {"id": "cpu", "attribute": "cpu_score", "strategy": "numeric_linear", "direction": "higher", "acceptable": 60, "ideal": 100, "weight": 0.10} + ] +} +``` + +Jede Präferenz besitzt eine explizite `strategy`. v0.1 unterstützt ausschließlich `numeric_linear`. Das Profilformat ist jedoch so zu modellieren, dass spätere Scoring-Versionen zusätzliche klar benannte Strategien wie `boolean_preferred`, `categorical_match`, `set_contains` oder `categorical_rank` einführen können, ohne die Grundstruktur eines Profils zu ändern. Für v0.1 wird dafür weder ein Plugin-System noch dynamische Code-Ausführung gebaut; unbekannte Strategien werden bei der Profilvalidierung abgewiesen. + +Validierung: `0 < target < maximum`, mindestens eine Präferenz, positive Gewichte, innerhalb der jeweiligen Liste eindeutige IDs, bekannte Attributschlüssel und eine vorhandene Scoring-Version. Die Summe der Gewichte muss nicht 1 sein, weil die Engine normalisiert. Für `numeric_linear` gilt: `higher` verlangt `ideal > acceptable`, `lower` verlangt `ideal < acceptable`, und das referenzierte Attribut muss numerisch sein. Kategorie, Währung, Markt und erlaubte Zustände sind verpflichtend. + +Das Profil ist ein Beispiel aus der Architekturentscheidung, keine abschließend optimierte persönliche Kaufberatung. Die Auswahl eines Notebooks für mobile Entwicklung wird nicht mit Anforderungen an einen lokalen LLM-Server vermischt. + +## 6. Constraint Engine und Eignungsstatus + +Unterstütze ausschließlich folgende Operatoren; verwerfe ungültige Kombinationen bereits beim Anlegen des Profils: + +| Operator | Attributtyp | Vergleichswert / Semantik | +|---|---|---| +| `>=`, `<=`, `>`, `<` | number | Zahl in kanonischer Einheit | +| `==`, `!=` | number, string, boolean | gleicher skalarer Typ, exakter Vergleich | +| `in` | number, string, boolean | nichtleere Liste gleichartiger skalarer Werte | +| `contains` | string_list | einzelner String, exakte Mitgliedschaft | +| `exists` | alle | kein `value`; verwertbarer normalisierter Wert vorhanden | + +Keine impliziten Typkonvertierungen, String-Teilstrings oder case-insensitive Vergleiche. Leere String-Listen sind vorhandene Werte. Fehlend, null, nicht normalisiert oder Confidence unter der Regelgrenze ergibt `UNKNOWN`, auch bei `exists` und `!=`. + +Jedes Ergebnis enthält Constraint-ID, Attribut, Operator, Sollwert, Istwert, Status `PASS|FAIL|UNKNOWN`, Mandatory-Flag, gewählte Attributzeilen-ID und Reason-Code. + +### Product Eligibility + +`product_eligibility` beschreibt ausschließlich, ob die konkrete Produktvariante fachlich zum Profil passt. Dazu gehören: + +- Kategorie des Produkts/der Variante, +- alle mandatory Produkt-Constraints, +- technische bzw. produktbezogene Daten, die für diese Constraints benötigt werden. + +Ableitung: + +- mindestens ein bekannter produktbezogener harter `FAIL` → `product_eligibility=INELIGIBLE`; +- sonst mindestens ein mandatory produktbezogener `UNKNOWN` → `product_eligibility=UNKNOWN`; +- sonst → `product_eligibility=ELIGIBLE`. + +Optionale Constraints ändern `product_eligibility` nicht. Optionales `FAIL` oder `UNKNOWN` reduziert ausschließlich den ProductScore um die im Scoring-Modell konfigurierte Punktzahl und bleibt in den Ergebnissen sichtbar. + +Der ProductScore darf unabhängig von der aktuellen Angebotslage berechnet und als Diagnostik gespeichert werden, sofern das Profil und die vorhandenen Präferenzdaten dies zulassen. Ein technisch sehr gutes Produkt verliert seinen ProductScore nicht nur deshalb, weil das betrachtete Offer aktuell zu teuer oder nicht lieferbar ist. + +### Offer Eligibility + +`offer_eligibility` beschreibt ausschließlich die Eignung des konkret ausgewerteten Offers. Systemseitige Offer-Gates sind: + +- Markt entspricht dem Profil, +- Zustand ist in `allowed_conditions`, +- Währung entspricht der Profilwährung, +- aktuelle Preisbeobachtung ist vorhanden und frisch, +- Preis-Confidence erfüllt die Mindestschwelle, +- obligatorische Versandkosten sind bekannt, +- Gesamtpreis <= Budgetmaximum, +- aktuelle Verfügbarkeit ist `in_stock`. + +Ein bekannter Verstoß ergibt `offer_eligibility=INELIGIBLE`. Fehlende/zu alte Preisbeobachtung, geringe Preis-Confidence, unbekannte Versandkosten oder unbekannte Verfügbarkeit ergeben `offer_eligibility=UNKNOWN`. `out_of_stock` ergibt `INELIGIBLE`. Ein Preis oberhalb des Budgets ist ein Angebotsproblem und macht nicht das Produkt selbst ungeeignet. + +### Overall Eligibility + +`overall_eligibility` wird ausschließlich aus Product und Offer Eligibility abgeleitet: + +- wenn mindestens einer der beiden Status `INELIGIBLE` ist → `overall_eligibility=INELIGIBLE`; +- sonst wenn mindestens einer `UNKNOWN` ist → `overall_eligibility=UNKNOWN`; +- sonst → `overall_eligibility=ELIGIBLE`. + +Alle Gates werden ausgewertet, damit die Antwort sämtliche Gründe enthält. Ein bekannter harter Fehler hat innerhalb der jeweiligen Ebene Vorrang vor unbekannten Daten. Das API-Feld `eligible` ist eine Convenience-Abbildung von `overall_eligibility`: `ELIGIBLE` → true, `INELIGIBLE` → false, `UNKNOWN` → null. + +## 7. Scoring-Modell `generic-v1` + +Alle Scores liegen zwischen 0 und 100. Rechne intern mit Decimal, runde nur endgültige Scores und angezeigte Komponenten auf zwei Dezimalstellen, `ROUND_HALF_UP`. Klassifikation anhand des ungerundeten DealScore. Der Snapshot enthält auch ungerundete Dezimalstrings für eine eindeutige Grenzfallprüfung. + +Die unveränderliche Modelldefinition enthält mindestens: + +```json +{ + "version": "generic-v1", + "algorithm_id": "linear-budget-history-v1", + "supported_preference_strategies": ["numeric_linear"], + "minimum_confidence": 0.8, + "maximum_price_age_hours": 48, + "optional_constraint_penalty_points": 5, + "missing_preference_score": 0, + "product_weight": 0.6, + "price_weight": 0.4, + "budget_weight": 0.4, + "history_weight": 0.6, + "history_window_days": 90, + "minimum_history_days": 3, + "history_discount_for_max_score": 0.2, + "classification_upper_bounds": [ + {"upper": 60, "inclusive": false, "label": "IGNORE"}, + {"upper": 75, "inclusive": false, "label": "WATCH"}, + {"upper": 85, "inclusive": false, "label": "GOOD"}, + {"upper": 92, "inclusive": true, "label": "VERY_GOOD"}, + {"upper": 100, "inclusive": true, "label": "EXCEPTIONAL"} + ] +} +``` + +`clamp(x)=min(100,max(0,x))`. Modellvalidierung prüft positive Zeitfenster, gültige Confidence, nichtnegative Gewichte mit jeweiliger Summe 1, aufsteigende lückenlose Klassifikationsgrenzen, unterstützte Algorithmus-ID und eine nichtleere Liste unterstützter Preference-Strategien. Die Definition wird als kanonisches JSON inklusive SHA-256 gespeichert. Eine Version darf niemals mit geändertem Inhalt wiederverwendet werden. Neue Algorithmen oder Preference-Strategien werden als Code ergänzt und unter einer neuen Scoring-Version bzw. Algorithmus-ID registriert; JSON darf keinen ausführbaren Code enthalten. + +### ProductScore + +v0.1 implementiert ausschließlich die Preference-Strategie `numeric_linear`. Für eine solche numerische Präferenz mit Istwert `x`, akzeptablem Wert `a` und Idealwert `i`: + +```text +higher: component = clamp(50 + 50 × (x − a) / (i − a)) +lower: component = clamp(50 + 50 × (a − x) / (a − i)) +base_product_score = Σ(weight × component) / Σ(weight) +product_score = clamp(base_product_score − 5 × Anzahl optionaler FAIL/UNKNOWN) +``` + +Damit erhält ein akzeptabler Wert 50, ein idealer oder besserer Wert maximal 100. Ein fehlender oder unsicherer Präferenzwert erhält 0; sein Gewicht bleibt im Nenner. Fehlende Daten dürfen den Score nicht verbessern. `product_coverage` ist die Summe der Gewichte verwertbarer Präferenzen geteilt durch die Summe aller Gewichte. Confidence wird nicht nochmals als Score-Multiplikator verwendet. + +Der ProductScore beschreibt Produkt-Fit und wird unabhängig von `offer_eligibility` berechnet. Auch bei `product_eligibility=INELIGIBLE` oder `UNKNOWN` darf er diagnostisch ausgegeben werden; Mandatory Gates bleiben jedoch autoritativ und können niemals durch einen hohen ProductScore überstimmt werden. + +Die Strategy-Auswahl erfolgt explizit über das Preference-Feld `strategy`; es gibt keine dynamische Plugin-Suche. Unbekannte Strategien sind für `generic-v1` ein Validierungsfehler. + +### Auswahl des aktuellen Preises und der Referenzhistorie + +Der Evaluation-Request bezeichnet genau ein Offer. Kein implizites billigstes Angebot und kein Vermischen mehrerer Händler oder Varianten. + +v0.1 verwendet als `reference_history` ausschließlich Preisbeobachtungen dieses konkreten Offers. Diese Bindung ist eine Entscheidung der Scoring-Version, nicht des generischen Preis-Scoring-Vertrags. Die reine Preis-Scoring-Funktion ist daher konzeptionell als Berechnung über `current_price`, `reference_history` und `budget` zu implementieren; sie darf fachlich nicht verlangen, dass die Referenzserie dauerhaft ein Offer-Objekt ist. Spätere Scoring-Versionen dürfen Variant-Level- oder Market-Level-Referenzserien bereitstellen, ohne Product, ProductVariant, Offer oder PriceObservation neu zu modellieren. + +Für v0.1 gilt für die offer-bezogene Referenzserie: + +1. Betrachte ausschließlich Beobachtungen dieses Offers mit `observed_at <= as_of` und `ingested_at <= snapshot_cutoff`. +2. Aktuell ist die jüngste Zeile nach `(observed_at, ingested_at, id)` absteigend. Auch eine nicht verfügbare oder unsichere jüngste Zeile darf nicht durch eine ältere günstigere ersetzt werden. +3. Frische: `as_of − observed_at <= 48 Stunden`, einschließlich Grenze. +4. Historie endet strikt vor dem UTC-Kalendertag der aktuellen Beobachtung. Dadurch wird der aktuelle Angebotspreis nicht selbst zur historischen Referenz. +5. Für jedes Fenster N ∈ {30,90,365} gilt `[current_day − N Tage, current_day)`. Wähle zunächst pro UTC-Tag die jüngste Zeile, danach filtere Tage mit `in_stock`, bekannter Gesamtsumme und Confidence >= 0.8. So verdrängt eine spätere unbekannte Beobachtung eine frühere bekannte desselben Tages. +6. Berechne `median_30d`, `median_90d`, `lowest_365d` aus diesen Tageswerten. Bei gerader Anzahl ist der Median das arithmetische Mittel der beiden mittleren Werte. Gib die Zahl der vorhandenen Tage pro Fenster mit aus; keine Interpolation fehlender Tage. + +Historienaggregate werden berechnet und im Evaluation-Snapshot gespeichert, nicht als vom Client beliebig vorgegebene Wahrheit übernommen. Die v0.1-Historie ist angebotsbezogen und keine Aussage über den Gesamtmarkt. `lowest_365d` darf als Minimum weniger beobachteter Tage angezeigt werden, muss dann ausdrücklich als Minimum der vorhandenen Daten bezeichnet sein. Der Snapshot speichert zusätzlich `price_reference_scope="offer"` und die IDs der verwendeten Beobachtungen, damit spätere Scoring-Versionen andere Scopes eindeutig unterscheiden können. + +### PriceScore + +Seien `p` aktueller Gesamtpreis, `t` Budgetziel, `m` Budgetmaximum, `r` 90-Tage-Median der von der Scoring-Version bereitgestellten Referenzhistorie: + +```text +budget_score = 100 falls p <= t +budget_score = clamp(100 × (m − p) / (m − t)) sonst +discount = (r − p) / r +history_score = clamp(50 + 50 × discount / 0.20) +price_score = 0.40 × budget_score + 0.60 × history_score +``` + +Historien-Score: beim Median 50, bei 20 % günstiger 100, bei 20 % teurer 0. Nur bei mindestens drei verfügbaren Tagen im 90-Tage-Fenster ist `history_score` vorhanden. Andernfalls gilt `price_score=budget_score`, `price_basis=BUDGET_ONLY`, `history_score=null` und ein expliziter Hinweis `INSUFFICIENT_HISTORY`. Bei ausreichender Historie `price_basis=BUDGET_AND_HISTORY`. Kein Bonus für ein vermeintliches Jahrestief; 30-Tage-Median und Minimum dienen zunächst nur der Erklärung. + +Ohne frischen, hinreichend sicheren und vollständig bekannten Gesamtpreis in Profilwährung ist der PriceScore null. Ein bekannter Preis oberhalb des Budgets kann diagnostisch bewertet werden, während `offer_eligibility` wegen des Budget-Gates `INELIGIBLE` bleibt. Bei falscher Währung wird ohne Wechselkursumrechnung kein PriceScore berechnet. Ausverkaufte Angebote dürfen ihren bekannten Preis diagnostisch behalten; `offer_eligibility` bleibt dennoch `INELIGIBLE`. + +### DealScore und Klassifikation + +Nur bei `overall_eligibility=ELIGIBLE` und vorhandenem PriceScore: + +```text +deal_score = 0.60 × product_score + 0.40 × price_score +``` + +| Bereich des ungerundeten DealScore | Klassifikation | +|---|---| +| < 60 | IGNORE | +| >= 60 und < 75 | WATCH | +| >= 75 und < 85 | GOOD | +| >= 85 und <= 92 | VERY_GOOD | +| > 92 | EXCEPTIONAL | + +Bei `overall_eligibility=INELIGIBLE`: DealScore null, Klassifikation `INELIGIBLE`. Bei `overall_eligibility=UNKNOWN`: DealScore null, Klassifikation `INSUFFICIENT_DATA`. ProductScore und vorhandener PriceScore dürfen als Diagnostik ausgegeben werden, ersetzen aber niemals die Gate-Ergebnisse. Die getrennten Product-/Offer-Status müssen in Snapshot und API sichtbar bleiben, damit ein gutes Produkt mit momentan schlechtem Angebot weiterhin beobachtbar bleibt. + +### Nachvollziehbarkeit und Wiederholbarkeit + +Jede Evaluation speichert Profil und Version, Modellversion und Hash, Engine-Build-ID, `as_of`, `snapshot_cutoff`, `product_eligibility`, `offer_eligibility`, `overall_eligibility`, vollständige tatsächlich verwendete Eingaben und IDs sowie alle Komponenten, Gates, Aggregate, Coverage, Price-Reference-Scope, Reason-Codes und verständliche Texte. Persistierte Evaluationen sind unveränderlich. Ein erneuter Request erzeugt eine neue Evaluation; ein GET berechnet nichts neu. + +Historische Abfragen mit `as_of` rekonstruieren nur den Datenstand zum aktuellen Snapshot-Cutoff, einschließlich später eingegangener Rückdatierungen. Sie behaupten nicht, den damaligen Wissensstand wiederherzustellen. Exakte Wiederholung einer alten Bewertung erfolgt intern aus deren gespeichertem Snapshot und zugehöriger Algorithmusversion. Alte Algorithmen bleiben verfügbar; kein Replay-HTTP-Endpunkt für v0.1 erforderlich. + +## 8. Source-Adapter + +Implementiere einen kleinen `Protocol` und `ManualSource`. Quell-DTOs enthalten Rohdaten, Quellenkennung, externe IDs, Zeitpunkte und Confidence; sie enthalten keine ORM-Objekte und keine Scoring-Logik. + +```python +class ProductSource(Protocol): + source_key: str + def get_product(self, external_id: str) -> SourceProduct: ... + def get_offers(self, external_product_id: str) -> list[SourceOffer]: ... + def get_price_history(self, external_offer_id: str) -> list[SourcePriceObservation]: ... +``` + +`ManualSource` liest einen validierten lokalen Demo-Datensatz, keine externe Website. Ein Import-Service nimmt den Adapter entgegen und legt über dieselben Application Services wie die API Produkte, Varianten, Attribute, Angebote und Beobachtungen an. Explizite Variantenzuordnung aus der Fixture; keine Matching-Engine. Wiederholter Demo-Import erzeugt keine Duplikate: stabile UUIDs für Produkte/Varianten/Attribute, stabile externe Offer-/Beobachtungs-IDs und identischer Inhalt; Widersprüche melden statt überschreiben. + +Adapter normalisieren keine Preise oder technischen Eigenschaften im Scoring-Modul. Der Pfad lautet Quelle → validierte DTOs → Normalisierung → Persistenz → Evaluation. Für spätere Quellen genügen zunächst Adapter und Import-Mapping. Pagination, Rate Limits und Retry-Strategien erst zusammen mit der ersten realen Quelle einführen. + +## 9. HTTP-API + +Alle fachlichen Endpunkte unter `/api/v1`. UUIDs für Entitäten, String-IDs für Profile, Attribute und Scoring-Modelle. Pydantic-Schemas mit `extra=forbid`; Pflichtfelder und Enums im OpenAPI-Schema dokumentieren. JSON-Ausgabe nutzt `snake_case`. + +| Methode / Pfad | Vertrag | +|---|---| +| GET `/health/live` | Prozess lebt, kein DB-Zugriff | +| GET `/health/ready` | DB erreichbar und Migration auf Head; sonst 503 | +| POST `/api/v1/attribute-definitions` | unveränderliche Definition anlegen | +| GET `/api/v1/attribute-definitions` | Definitionen listen | +| GET `/api/v1/sources` | registrierte Quellen, einschließlich `manual` | +| GET `/api/v1/scoring-models/{version}` | vollständige Regeldefinition + Hash | +| POST `/api/v1/profiles` | vollständiges Profil inklusive Version anlegen | +| GET `/api/v1/profiles` | konkrete Profilversionen listen | +| GET `/api/v1/profiles/{id}?version=1` | konkrete Version; Query verpflichtend | +| POST `/api/v1/products` | Product anlegen | +| GET `/api/v1/products/{id}` | Product lesen | +| POST `/api/v1/products/{id}/variants` | Variante anlegen | +| GET `/api/v1/variants/{id}` | Variante mit Attributaussagen lesen | +| POST `/api/v1/variants/{id}/attributes` | einzelne unveränderliche Attributaussage hinzufügen | +| POST `/api/v1/variants/{id}/offers` | Offer anlegen | +| GET `/api/v1/offers/{id}` | Offer lesen | +| POST `/api/v1/offers/{id}/prices` | einzelne Beobachtung hinzufügen bzw. identisch wiederholen | +| GET `/api/v1/offers/{id}/prices` | gespeicherte Beobachtungen listen | +| POST `/api/v1/evaluate/{variant_id}` | auswerten und Ergebnis atomar speichern | +| GET `/api/v1/evaluations/{id}` | gespeichertes Ergebnis inklusive Snapshot lesen | + +Listen verwenden `limit` (Default 50, maximal 200) und `offset` (>=0); Antwort `{items, limit, offset, total}`. Stabile Sortierung nach Primärschlüssel, bei Preisen `(observed_at, ingested_at, id)` aufsteigend. Keine Update-/Delete-Endpunkte in v0.1. + +Evaluation-Request: + +```json +{ + "profile_id": "mobile-development-notebook", + "profile_version": 1, + "offer_id": "33333333-3333-4333-8333-333333333333", + "as_of": "2026-10-03T12:00:00Z" +} +``` + +`as_of` ist optional; der Service setzt es einmal auf Serverzeit UTC. Zukunftswerte sind ungültig. Offer muss zur angegebenen Variante gehören (422). Das Profil legt die Scoring-Version fest; kein stiller Override. Fachlich unpassende Kategorie wird im Product-Gate gespeichert; Markt, Zustand, Währung, Preis und Verfügbarkeit werden im Offer-Gate gespeichert. Keiner dieser fachlichen Fälle ist ein HTTP-Fehler. + +Evaluation-Response enthält mindestens: + +```json +{ + "id": "44444444-4444-4444-8444-444444444444", + "profile_id": "mobile-development-notebook", + "profile_version": 1, + "scoring_version": "generic-v1", + "product_eligibility": "ELIGIBLE", + "offer_eligibility": "ELIGIBLE", + "overall_eligibility": "ELIGIBLE", + "eligible": true, + "product_score": 90.21, + "price_score": 98.24, + "deal_score": 93.42, + "classification": "EXCEPTIONAL", + "price_basis": "BUDGET_AND_HISTORY", + "product_coverage": 1.0, + "failed_constraints": [], + "unknown_constraints": [], + "reasons": [ + {"code": "PRICE_BELOW_MEDIAN", "message": "Gesamtpreis 18,83 % unter dem Median der beobachteten 90-Tage-Historie."} + ] +} +``` + +Zusätzlich verpflichtend: `variant_id`, `offer_id`, `as_of`, `snapshot_cutoff`, `scoring_hash`, `engine_build`, `product_eligibility`, `offer_eligibility`, `overall_eligibility`, `components`, sämtliche `constraint_results` und Offer-Gates, `price_statistics`, `price_reference_scope`, `input_snapshot` und `warnings`. Die Kurzantwort oben illustriert nur die Kernfelder. `failed_constraints` enthält alle `FAIL`-Ergebnisse; `unknown_constraints` alle `UNKNOWN`-Ergebnisse. Reason-Codes sind stabil, Texte dienen Menschen und dürfen nicht als Programmierschnittstelle ausgewertet werden. + +POST liefert 201, identisch wiederholte Preisbeobachtung 200; GET liefert 200. Fehlende IDs: 404. Verletzte Eindeutigkeit/abweichender Wiederholungsinhalt: 409. Ungültige Payload/Referenzkombination: 422. Einheitliches Fehlerformat `{error: {code, message, details}}`, auch für Request-Validierung. Keine SQL-Details an Clients. Eine fachlich negative Evaluation ist eine erfolgreiche 201-Antwort. + +## 10. Datenbank und Transaktionen + +Alle Zeitstempel `TIMESTAMPTZ`; JSON-Daten `JSONB`; UUID-Primärschlüssel für Entitäten; Decimal-Geldfelder. `created_at`/`ingested_at` werden serverseitig gesetzt. Fremdschlüssel sind obligatorisch. Kein Cascade-Delete historischer Bewertungen oder Quelldaten. + +| Tabelle | Wesentliche Spalten und Constraints | +|---|---| +| `attribute_definitions` | `key` PK, Typ, Einheit, Bereich, Beschreibung | +| `sources` | `id` String-PK, `name`, `adapter_type`; Seed `manual` | +| `scoring_models` | `version` PK, `algorithm_id`, `definition` JSONB, `definition_hash`, `created_at` | +| `product_profiles` | zusammengesetzter PK `(id, version)`, `definition` JSONB, `category`, FK `scoring_version`, `definition_hash`, `created_at` | +| `products` | `id`, Kategorie, Marke, Name, Modell, `created_at` | +| `product_variants` | `id`, FK Product, Name, Hersteller-SKU, Kennungen JSONB, `created_at` | +| `variant_attributes` | `id`, FK Variante/Definition/Quelle, Raw/Normalized JSONB, Einheit, Status, Normalizer-Version, Confidence, Source-Referenz, Beobachtungs-/Ingestion-Zeit, Fehler | +| `offers` | `id`, FK Variante/Quelle, externe ID, Händler, URL, Markt, Währung, Zustand; UNIQUE Quelle/externe ID | +| `price_observations` | `id`, FK Offer/Quelle, externe Beobachtungs-ID, Preise, Währung, Verfügbarkeit, Confidence, Raw JSONB, beide Zeiten; UNIQUE Offer/externe ID | +| `evaluations` | `id`, FK Variante/Offer/Profilversion/Scoring-Version, Zeitpunkte, Hash, Build-ID, `product_eligibility`, `offer_eligibility`, `overall_eligibility`, drei nullable Scores, Klassifikation, `price_reference_scope`, vollständige Eingabe-/Ergebnis-Snapshots JSONB | +| `evaluation_components` | `id`, FK Evaluation, `kind`, `component_key`, nullable Rohscore/Gewicht/Beitrag, `details` JSONB; UNIQUE Evaluation/kind/key | + +`evaluation_components` enthält Präferenzen inklusive Strategy, optionale Abzüge, Budget-/History-Komponenten, Product-Gates und Offer-Gates. JSON-Snapshots sind die vollständige Replay-Grundlage; relationale Komponenten dienen Abfragen und dürfen bei Erstellung nicht davon abweichen. + +DB-Checks mindestens für nichtnegative Preise, positiven Artikelpreis, Confidence 0–1, Scores 0–100, Profilversion >0 und gültige feste Enums. Typ-/Einheiten-/Profilvalidierung bleibt zusätzlich in der Application-Schicht. Redundante Indexspalten eines Profils müssen aus dessen validierter Definition erzeugt werden. + +Indizes: Attribute `(variant_id, attribute_key, observed_at DESC, ingested_at DESC, id DESC)`, Preise `(offer_id, observed_at DESC, ingested_at DESC, id DESC)`, Offers `variant_id`, Evaluationen `(profile_id, profile_version, created_at DESC)` und `variant_id`. + +Eine Evaluation liest und schreibt innerhalb einer PostgreSQL-Transaktion mit konsistentem Snapshot (`REPEATABLE READ`). Zu Beginn `snapshot_cutoff` festlegen; komplette Inputs, Ergebnis und Komponenten atomar committen. Bei Fehlern kein Teilergebnis. Insert-Konflikte über UNIQUE-Constraints behandeln, nicht allein per vorherigem SELECT. + +Alembic ist die einzige produktive Schemaanlage; kein `metadata.create_all()` beim API-Start. Initialmigration enthält Tabellen, Constraints und Indizes. Bootstrap von Quellen und Regeldefinitionen separat, idempotent und inhaltlich geprüft. Ein Konflikt gleicher Version mit anderem Hash stoppt den Bootstrap sichtbar. + +## 11. Repository und Lieferumfang + +```text +buying-agent/ +├── README.md +├── Dockerfile +├── docker-compose.yml +├── .dockerignore +├── .env.example +├── .gitignore +├── pyproject.toml +├── uv.lock +├── alembic.ini +├── migrations/ +│ ├── env.py +│ └── versions/ +├── app/ +│ ├── main.py +│ ├── settings.py +│ ├── api/ +│ │ ├── schemas.py +│ │ ├── profiles.py +│ │ ├── products.py +│ │ ├── offers.py +│ │ ├── evaluations.py +│ │ └── health.py +│ ├── domain/ +│ │ ├── models.py +│ │ ├── attributes.py +│ │ ├── constraints.py +│ │ └── enums.py +│ ├── scoring/ +│ │ ├── product.py +│ │ ├── price.py +│ │ ├── deal.py +│ │ └── engine.py +│ ├── normalization/ +│ │ ├── base.py +│ │ └── manual.py +│ ├── sources/ +│ │ ├── base.py +│ │ └── manual.py +│ ├── persistence/ +│ │ ├── db.py +│ │ ├── models.py +│ │ └── repositories.py +│ └── services/ +│ ├── ingestion_service.py +│ └── evaluation_service.py +├── rules/generic-v1.json +├── profiles/mobile-development-notebook.json +├── fixtures/manual-demo.json +├── scripts/ +│ ├── bootstrap.py +│ └── demo.py +└── tests/ + ├── unit/ + ├── integration/ + └── e2e/ +``` + +`__init__.py` nach Bedarf ergänzen. Kleine Dateien dürfen sinnvoll zusammengelegt werden; Architekturgrenzen sind verbindlich. Kein generisches Repository-Framework und kein Plugin-Discovery-System erforderlich. Das Lockfile-Beispiel verwendet uv; uv als Build-/Entwicklungswerkzeug mit dokumentierter Version verwenden. + +README enthält Setup, Architektur, Datenmodell, Formeln, Fehlersemantik, alle Start-/Testkommandos, vollständige Beispielrequests sowie Grenzen der synthetischen Preisbewertung. Diese Spezifikation ebenfalls im Repository unter `docs/buying-agent-v0.1-spec-v1.1.md` ablegen. + +## 12. Docker und lokale Ausführung + +Compose enthält nur zwei dauerhaft laufende Services: `api` und `postgres`. PostgreSQL 17 mit benanntem Datenvolume und Healthcheck. API basiert auf Python 3.13, läuft als Nicht-Root und bindet den veröffentlichten Port an `127.0.0.1:8000`. Die Datenbank braucht keinen veröffentlichten Host-Port. Secrets und lokale DB-Zugänge über `.env`, nur Platzhalter in `.env.example`. + +Für das lokale Einzelinstanz-Skeleton darf das API-Entrypoint nach erfolgreichem DB-Healthcheck sequenziell `alembic upgrade head`, Bootstrap und Uvicorn ausführen. Fehlgeschlagene Migrationen verhindern den API-Start. Diese Startmigration ist keine Strategie für spätere Mehrinstanz-Deployments. + +Verbindlicher Startpfad: + +```sh +cp .env.example .env +docker compose up --build -d +docker compose exec api python -m scripts.demo +``` + +Für PowerShell zusätzlich `Copy-Item .env.example .env` dokumentieren. `scripts.demo` legt Definitionen, Profil und synthetische Kandidaten über die API an, wiederholt sich ohne Duplikate und ruft Evaluationen mit explizitem `as_of` auf. Die Demo umfasst mindestens die Fälle A, B, C und D aus Abschnitt 13. Statische Fixtures nutzen den unten definierten historischen Zeitpunkt, damit sie nicht mit der Kalenderzeit altern. Der Adapterpfad wird separat im Integrationstest geprüft; die Demo muss den HTTP-Pfad abdecken. + +Testabhängigkeiten in einer Docker-Build-Stage `test` bereitstellen. README dokumentiert einen ausführbaren Compose-Test-Override oder gleichwertigen Befehl mit dieser Stage und separater Testdatenbank. Tests dürfen niemals das normale Datenvolume leeren. Kein dauerhafter dritter Service nötig. + +## 13. Deterministische Beispiele und Tests + +### Referenzfall A + +`as_of=2026-10-03T12:00:00Z`, aktuelle Beobachtung um 10:00 UTC; alle technischen Attribute vorher beobachtet. Kategorie Notebook, Markt DE, Zustand neu, EUR, lieferbar, Confidence jeweils 1.0, Versand 0.00. + +| Attribut | Wert | Präferenzscore | +|---|---:|---:| +| ram_gb | 32 | 100 | +| storage_gb | 1024 | nur Mindestconstraint | +| weight_kg | 1.2 | 83.333333… | +| battery_score | 90 | 87.5 | +| display_score | 90 | 87.5 | +| cpu_score | 90 | 87.5 | + +Gesamtpreise der Historie: 1599.00 am 2026-09-01, 1599.00 am 2026-09-15, 1599.00 am 2026-10-01; jeweils 10:00 UTC. Aktuell 1298.00. Alle Zeilen betreffen dasselbe Offer und sind lieferbar. Fixtures werden vor Evaluation vollständig importiert. + +Erwartungen: + +```text +product_eligibility = ELIGIBLE +offer_eligibility = ELIGIBLE +overall_eligibility = ELIGIBLE; eligible = true +product_score = 90.21 +median_30d = 1599.00 (2 Tage) +median_90d = 1599.00 (3 Tage) +lowest_365d = 1599.00 (3 Tage) +budget_score = 100 +history_score = 97.06 +price_score = 98.24 +deal_score = 93.42 +classification = EXCEPTIONAL +product_coverage = 1.0 +``` + +Die Engine rechnet mit den ungerundeten Zwischenwerten. Die Response aus Abschnitt 9 bezieht sich auf diesen Fall. Das beobachtete Minimum von 1599.00 ist ausdrücklich kein belegtes Jahrestief des Marktes. + +### Weitere Demokandidaten + +- B: identisch zu A, aber 8 GB RAM → `product_eligibility=INELIGIBLE`, `offer_eligibility=ELIGIBLE`, `overall_eligibility=INELIGIBLE`, Mandatory RAM `FAIL`, ProductScore weiterhin diagnostisch vorhanden, kein DealScore. +- C: identisch zu A, aber keine verwertbare Gewichtsangabe → `product_eligibility=UNKNOWN`, `offer_eligibility=ELIGIBLE`, `overall_eligibility=UNKNOWN`, Mandatory Gewicht `UNKNOWN`, ProductScore diagnostisch vorhanden, Klassifikation `INSUFFICIENT_DATA`, kein DealScore. +- D: technisch identisch zu A, aber aktueller Gesamtpreis 2049.00 bei Budgetmaximum 2000.00 → `product_eligibility=ELIGIBLE`, `offer_eligibility=INELIGIBLE`, `overall_eligibility=INELIGIBLE`; ProductScore und diagnostischer PriceScore bleiben vorhanden, DealScore null. Dieser Fall beweist die Trennung von Produkt-Fit und Angebots-Fit. + +### Unit-Tests + +- Jeden Constraint-Operator mit PASS, FAIL, UNKNOWN; Gleichheitsgrenzen, strikte Typen, leere Listen und Confidence exakt 0.8 bzw. knapp darunter. +- Preference-Strategie `numeric_linear`: akzeptabel 50, ideal 100, Clamping, lower/higher, nicht auf 1 summierte Gewichte, fehlende Werte und optionale Abzüge; unbekannte `strategy` wird für `generic-v1` abgewiesen. +- Budget bei Ziel/Maximum/darüber; History-Score bei −20 %, 0 % und +20 % Rabatt; BUDGET_ONLY bei 0–2 Historientagen. +- Tagesauswahl vor Qualitätsfilter, Fenstergrenzen, Ausschluss des aktuellen Tages, Median gerader/ungerader Anzahl, identische Zeitstempel und stabile Tie-Breaks. +- Keine Vermischung von Offers, Varianten, Währungen oder Zuständen; aktuelle Zeile unbekannt/ausverkauft ohne Rückfall auf ältere Zeilen; ProductScore bleibt von Offer-Gates unabhängig. +- Frische exakt 48 Stunden/knapp darüber; unbekannter Versand; fehlender Preis; Kombination Mandatory FAIL und UNKNOWN. +- Klassifikationsgrenzen 60, 75, 85, 92 und unmittelbar darüber; Rundung darf keine Klasse verschieben. +- Referenzfall A einschließlich exakter gerundeter Resultate; andere Kategorie mit eigenen Attributen ohne Engine-Änderung; Fall D zur Trennung von Product/Offer Eligibility. +- Preis-Scoring-Funktion wird mit explizitem `current_price`, `reference_history` und `budget` getestet; Domain-Test darf dafür kein Offer-ORM-Objekt benötigen. `price_reference_scope=offer` ist für v0.1 verpflichtend. +- Replay aus gespeichertem Input trotz später hinzugefügter Attribute/Preise liefert identische fachliche Ergebnisse; generierte IDs und Speicherzeit sind kein Bestandteil des Vergleichs. + +### Integration und End-to-End + +- Gegen echtes PostgreSQL 17, nicht SQLite als Ersatz für JSONB, Numeric, Constraints oder Isolation. +- Migration auf leerer Test-DB; erneutes Upgrade ohne Änderung; initialer Downgrade/Upgrade nur in wegwerfbarer Test-DB. +- Bootstrap identisch wiederholbar, veränderte Regel mit gleicher Version wird abgewiesen. +- CRUD-Anlage-/Lesepfade, validierte Profilreferenzen, 404/409/422, Pagination, exakte Decimal- und UTC-Roundtrips. +- Doppelte Preisimporte auch bei konkurrierenden Requests: eine Beobachtung, kein stiller Content-Overwrite. +- Gescheiterte Evaluation-Persistenz hinterlässt weder Evaluation noch Komponenten. +- HTTP-End-to-End: Profil → Produkt → Variante → Attribute → Offer → Historie → Evaluation → GET; danach API-Neustart und erneutes GET desselben unveränderten Ergebnisses. +- ManualSource-Import nutzt dieselben Services und erzeugt bei Wiederholung keine Duplikate. +- Einfügen neuer Preisbeobachtungen ändert alte Evaluationen nicht; neue Evaluation verwendet neue Inputs. + +## 14. Implementierungsreihenfolge + +1. Projekt, Settings, Docker, PostgreSQL und initiale Migration aufsetzen. +2. Domain-Modelle, Definitions-/Profilvalidierung einschließlich `strategy`, getrennte Product-/Offer-Gates und reine Constraint-/Scoring-Funktionen samt Referenztests implementieren. +3. Preis-Scoring als reine Berechnung über `current_price`, `reference_history` und `budget` implementieren; v0.1-Assembler liefert dafür ausschließlich offer-bezogene Historie und markiert `price_reference_scope=offer`. +4. Repositories und transaktionalen Evaluation-Service integrieren. +5. API, ManualSource und Bootstrap anschließen; ersten vollständigen Durchstich ausführen. +6. Demo, Integrationstests, Fehlerfälle und nachvollziehbare Responses vervollständigen. +7. README und sauberen Start auf leerem Volume prüfen; tatsächliche Testergebnisse dokumentieren. + +Routineentscheidungen innerhalb dieser Spezifikation selbst treffen. Falls eine Vorgabe technisch widersprüchlich ist, den konkreten Konflikt dokumentieren und minimal auflösen; keine stillen fachlichen Formeländerungen oder Scope-Erweiterungen. Insbesondere keine Infrastruktur für spätere Preference-Strategien, Variant-/Market-Level-Preisreihen, n8n, LLMs oder Discovery vorwegnehmen. + +## 15. Definition of Done + +- [ ] Ein frischer Checkout startet mit den dokumentierten Docker-Kommandos; Migration und Readiness funktionieren. +- [ ] PostgreSQL persistiert Daten über API-/Container-Neustarts hinweg. +- [ ] Alle vier Entitäten Product, ProductVariant, Offer, PriceObservation sind getrennt und korrekt verknüpft. +- [ ] Attribute sind generisch und bewahren Raw inhaltlich unverändert, Normalized, Quelle, Confidence und Normalizer-Version. +- [ ] Beispielprofil und `generic-v1` liegen versioniert als Dateien vor und werden validiert gespeichert; jede Preference besitzt `strategy`, v0.1 akzeptiert ausschließlich `numeric_linear`. +- [ ] Product-, Offer- und Overall-Eligibility werden getrennt berechnet, persistiert und über die API ausgegeben. +- [ ] Ein technisch passendes Produkt behält ProductScore/Product Eligibility unabhängig davon, ob das konkrete Offer aktuell zu teuer, ausverkauft oder anderweitig ungeeignet ist. +- [ ] Das Preis-Scoring ist fachlich über `current_price`, `reference_history` und `budget` entkoppelt; v0.1 verwendet ausschließlich `price_reference_scope=offer` und implementiert keine Variant-/Market-Level-Historie. +- [ ] Demo liefert für A, B, C und D die festgelegten Ergebnisse über die API. +- [ ] Scores, harte Gates, fehlende Daten, Historienbasis, Reference Scope und Begründungen sind sichtbar und reproduzierbar. +- [ ] Evaluationen enthalten vollständige unveränderliche Snapshots; spätere Daten und Regelversionen verändern sie nicht. +- [ ] Unit-, PostgreSQL-Integrations- und HTTP-End-to-End-Tests sind grün; Kommandos und Resultate sind dokumentiert. +- [ ] Neue Kategorie und neue Attribute funktionieren ohne Schema- oder Engine-Änderung, solange sie von den in der gewählten Scoring-Version unterstützten Strategies/Operatoren abgedeckt werden. +- [ ] Keine reale externe Quelle, kein n8n, keine LLM-Normalisierung, keine Web-UI, keine Auto-Discovery, keine Variant-/Market-Level-Preisaggregation und keine vorsorgliche Plugin-Infrastruktur wurden eingebaut. +- [ ] README ermöglicht einem anderen Entwickler Setup, Demo, Tests und Verständnis der Grenzen ohne Chat-Kontext. + +Abschlussbericht des Coding-Agenten: implementierter Umfang, relevante Entscheidungen, ausgeführte Prüfungen mit Ergebnissen und gegebenenfalls konkrete verbleibende Abweichungen. Nicht ausgeführte Tests ausdrücklich als solche benennen. + +## 16. Future Extension Contract + +v0.1 ist ein Walking Skeleton des Zielsystems. Die folgenden Fähigkeiten sind ausdrücklich **keine v0.1-Deliverables**, die Architektur darf ihre spätere Ergänzung jedoch nicht durch produktspezifische Schemata, fest verdrahtete Scoring-Annahmen oder n8n-interne Fachlogik unnötig blockieren: + +- mehrere externe Produkt- und Preisquellen, +- source-spezifische Search-/Discovery-Adapter, +- Variant-Level- und Market-Level-Preisreferenzserien, +- weitere Produktkategorien ohne produktspezifische DB-Spalten, +- weitere explizit versionierte Preference-Strategien wie `boolean_preferred`, `categorical_match`, `set_contains` oder `categorical_rank`, +- LLM-gestützte Normalisierung mit Raw-Wert, normalisiertem Wert, Confidence und Normalizer-Version, +- n8n für Scheduling, Orchestrierung und Benachrichtigungen, +- automatische Kandidatensuche und Kandidatenaufnahme, +- mehrere parallele Buying-Profile und Watchlists. + +Dieser Vertrag verlangt **keine vorsorgliche Infrastruktur** für diese Funktionen. Insbesondere keine Plugin-Registry, keinen Event-Bus, keine Microservices, keine zusätzlichen Preisaggregationstabellen, keine LLM-Abstraktionsschicht und keine n8n-Workflows bauen, solange sie nicht für den aktuellen Slice erforderlich sind. + +Die stabilen Erweiterungspunkte von v0.1 sind: + +1. Source-Adapter liefern validierte DTOs in den bestehenden Importpfad. +2. Attribute und Constraints bleiben generisch und datengetrieben. +3. Preference-Strategien werden explizit benannt und durch versionierte Scoring-Modelle unterstützt. +4. PriceScore konsumiert eine von der Scoring-Version bereitgestellte Referenzserie statt dauerhaft an Offer-Identität gekoppelt zu sein. +5. Product-, Offer- und Overall-Eligibility bleiben getrennt, damit Monitoring später gute Produkte unabhängig von momentan schlechten Angeboten verfolgen kann. +6. n8n bleibt externer Orchestrator; die Buying Engine bleibt ohne n8n vollständig nutzbar und testbar. + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..454a81a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,42 @@ +FROM python:3.13-slim-bookworm AS base + +RUN apt-get update \ + && apt-get install -y --no-install-recommends libpq5 \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=ghcr.io/astral-sh/uv:0.10.11 /uv /usr/local/bin/uv + +WORKDIR /app + +ENV UV_COMPILE_BYTECODE=1 \ + UV_LINK_MODE=copy \ + PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PATH="/app/.venv/bin:$PATH" + +COPY pyproject.toml uv.lock ./ +RUN uv sync --frozen --no-dev --no-install-project + +FROM base AS runtime + +COPY . . +RUN uv sync --frozen --no-dev \ + && sed -i 's/\r$//' /app/scripts/startup.sh \ + && chmod +x /app/scripts/startup.sh \ + && useradd --create-home --uid 1000 app \ + && chown -R app:app /app + +USER app +EXPOSE 8000 + +CMD ["/app/scripts/startup.sh"] + +FROM base AS test + +COPY . . +RUN uv sync --frozen --extra test \ + && useradd --create-home --uid 1000 app \ + && chown -R app:app /app + +USER app +CMD ["pytest", "tests", "-ra"] diff --git a/README.md b/README.md index 6f673c5..f7d55b7 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,42 @@ # BuyingAgent -Lokales Arbeitsverzeichnis, verbunden mit Gitea: +Backend für einen generischen Buying-/Deal-Agenten. Der erste fachliche Schnitt ist in `.specs/buying-agent-v0.1-spec.md` beschrieben. -`http://192.168.2.144:3000/Lars/BuyingAgent.git` +Entwicklung auf `develop`. Push nach `develop` baut die Dev-Umgebung auf dem Raspberry Pi. Ein Merge-Commit nach `main` deployt Production. Details: `docs/DEPLOYMENT.md`. -Die eigentliche Spezifikation und Projektstruktur folgen. +## Stack + +Python 3.13, FastAPI, Pydantic 2, SQLAlchemy 2, Alembic, PostgreSQL 17, pytest, Docker Compose. Build-Tool: [uv](https://docs.astral.sh/uv/) 0.10.11. + +## Lokal starten + +```powershell +Copy-Item .env.example .env +docker compose up --build -d +``` + +```sh +cp .env.example .env +docker compose up --build -d +``` + +API: `http://127.0.0.1:8000` +Live: `GET /health/live` +Ready: `GET /health/ready` (PostgreSQL erreichbar und Alembic auf Head) + +## Tests + +```sh +docker compose -f docker-compose.test.yml up --build --abort-on-container-exit --exit-code-from api +``` + +Die Testdatenbank liegt auf tmpfs und leert kein normales Datenvolume. + +## Server + +| Umgebung | Branch | Verzeichnis | API | +|---|---|---|---| +| Development | `develop` | `/home/lars/docker/buyingagent-dev` | `http://192.168.2.49:8095` | +| Production | `main` | `/home/lars/docker/buyingagent` | `http://192.168.2.49:8006` | + +Repo: `http://192.168.2.144:3000/Lars/BuyingAgent.git` diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..1dfc84d --- /dev/null +++ b/alembic.ini @@ -0,0 +1,38 @@ +[alembic] +script_location = migrations +prepend_sys_path = . +sqlalchemy.url = driver://user:pass@localhost/dbname + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..3dc1f76 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ +__version__ = "0.1.0" diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/health.py b/app/api/health.py new file mode 100644 index 0000000..c8a003a --- /dev/null +++ b/app/api/health.py @@ -0,0 +1,45 @@ +from fastapi import APIRouter +from fastapi.responses import JSONResponse +from sqlalchemy import text + +from alembic.config import Config +from alembic.runtime.migration import MigrationContext +from alembic.script import ScriptDirectory + +from app.persistence.db import engine +from app.settings import get_settings + +router = APIRouter() + + +def _alembic_at_head() -> bool: + config = Config("alembic.ini") + script = ScriptDirectory.from_config(config) + with engine.connect() as connection: + context = MigrationContext.configure(connection) + return set(context.get_current_heads()) == set(script.get_heads()) + + +@router.get("/health/live") +def live() -> dict[str, str]: + return {"status": "ok"} + + +@router.get("/health/ready", response_model=None) +def ready() -> JSONResponse | dict[str, str]: + settings = get_settings() + try: + with engine.connect() as connection: + connection.execute(text("SELECT 1")) + if not _alembic_at_head(): + return JSONResponse( + {"status": "not_ready", "reason": "migration_behind_head"}, + status_code=503, + ) + except Exception: + return JSONResponse({"status": "not_ready", "reason": "database"}, status_code=503) + return { + "status": "ready", + "environment": settings.environment, + "engine_build": settings.engine_build, + } diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..b499933 --- /dev/null +++ b/app/main.py @@ -0,0 +1,10 @@ +from fastapi import FastAPI + +from app.api.health import router as health_router + +app = FastAPI( + title="Buying Agent", + version="0.1.0", + description="Walking skeleton for the buying and deal evaluation API.", +) +app.include_router(health_router) diff --git a/app/persistence/__init__.py b/app/persistence/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/persistence/db.py b/app/persistence/db.py new file mode 100644 index 0000000..05f09b0 --- /dev/null +++ b/app/persistence/db.py @@ -0,0 +1,18 @@ +from collections.abc import Generator + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker + +from app.settings import get_settings + +settings = get_settings() +engine = create_engine(settings.database_url, pool_pre_ping=True) +SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False) + + +def get_session() -> Generator[Session, None, None]: + session = SessionLocal() + try: + yield session + finally: + session.close() diff --git a/app/settings.py b/app/settings.py new file mode 100644 index 0000000..748fb8d --- /dev/null +++ b/app/settings.py @@ -0,0 +1,27 @@ +from functools import lru_cache + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env", extra="ignore") + + db_host: str = "postgres" + db_port: int = 5432 + db_name: str = "buyingagent" + db_user: str = "buyingagent" + db_password: str = "change_me" + environment: str = "development" + engine_build: str = "dev" + + @property + def database_url(self) -> str: + return ( + f"postgresql+psycopg://{self.db_user}:{self.db_password}" + f"@{self.db_host}:{self.db_port}/{self.db_name}" + ) + + +@lru_cache +def get_settings() -> Settings: + return Settings() diff --git a/docker-compose.dev-env.yml b/docker-compose.dev-env.yml new file mode 100644 index 0000000..0e6c881 --- /dev/null +++ b/docker-compose.dev-env.yml @@ -0,0 +1,61 @@ +# Server development stack: /home/lars/docker/buyingagent-dev +# No fixed container_name — Compose prefixes with the project name. + +services: + postgres: + image: postgres:17-alpine + environment: + POSTGRES_DB: "${DB_NAME:-buyingagent_dev}" + POSTGRES_USER: "${DB_USER:-buyingagent_dev}" + POSTGRES_PASSWORD: "${DB_PASSWORD:-dev_password_change_me}" + volumes: + - dev-buyingagent-db-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-buyingagent_dev} -d ${DB_NAME:-buyingagent_dev}"] + interval: 5s + timeout: 5s + retries: 10 + restart: unless-stopped + networks: + - dev-buyingagent-network + + api: + build: + context: . + dockerfile: Dockerfile + target: runtime + environment: + DB_HOST: postgres + DB_PORT: "5432" + DB_NAME: "${DB_NAME:-buyingagent_dev}" + DB_USER: "${DB_USER:-buyingagent_dev}" + DB_PASSWORD: "${DB_PASSWORD:-dev_password_change_me}" + ENVIRONMENT: "${ENVIRONMENT:-development}" + ENGINE_BUILD: "${ENGINE_BUILD:-dev}" + ports: + - "${BUYING_AGENT_API_PORT:-8095}:8000" + depends_on: + postgres: + condition: service_healthy + healthcheck: + test: + [ + "CMD", + "python", + "-c", + "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health/ready')", + ] + interval: 10s + timeout: 5s + retries: 18 + start_period: 90s + restart: unless-stopped + networks: + - dev-buyingagent-network + +volumes: + dev-buyingagent-db-data: + +networks: + dev-buyingagent-network: + driver: bridge diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 0000000..22aee50 --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,45 @@ +# Disposable test stack. Uses tmpfs so the normal data volume is never touched. +# docker compose -f docker-compose.test.yml up --build --abort-on-container-exit --exit-code-from api + +name: buyingagent-test + +services: + postgres: + image: postgres:17-alpine + environment: + POSTGRES_DB: buyingagent_test + POSTGRES_USER: buyingagent_test + POSTGRES_PASSWORD: test + tmpfs: + - /var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U buyingagent_test -d buyingagent_test"] + interval: 5s + timeout: 5s + retries: 10 + networks: + - buyingagent-test-network + + api: + build: + context: . + dockerfile: Dockerfile + target: test + environment: + DB_HOST: postgres + DB_PORT: "5432" + DB_NAME: buyingagent_test + DB_USER: buyingagent_test + DB_PASSWORD: test + ENVIRONMENT: test + ENGINE_BUILD: test + depends_on: + postgres: + condition: service_healthy + command: ["pytest", "tests", "-ra"] + networks: + - buyingagent-test-network + +networks: + buyingagent-test-network: + driver: bridge diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..64ee664 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,64 @@ +# Local skeleton and production stack. +# Local default: 127.0.0.1:8000. Production on the Pi: 0.0.0.0:8006 via .env. +# Host path on the server: /home/lars/docker/buyingagent + +services: + postgres: + image: postgres:17-alpine + container_name: buyingagent-db-prod + environment: + POSTGRES_DB: "${DB_NAME:-buyingagent}" + POSTGRES_USER: "${DB_USER:-buyingagent}" + POSTGRES_PASSWORD: "${DB_PASSWORD:-change_me}" + volumes: + - buyingagent-db-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-buyingagent} -d ${DB_NAME:-buyingagent}"] + interval: 5s + timeout: 5s + retries: 10 + restart: unless-stopped + networks: + - buyingagent-network + + api: + build: + context: . + dockerfile: Dockerfile + target: runtime + container_name: buyingagent-api + environment: + DB_HOST: postgres + DB_PORT: "5432" + DB_NAME: "${DB_NAME:-buyingagent}" + DB_USER: "${DB_USER:-buyingagent}" + DB_PASSWORD: "${DB_PASSWORD:-change_me}" + ENVIRONMENT: "${ENVIRONMENT:-production}" + ENGINE_BUILD: "${ENGINE_BUILD:-dev}" + ports: + - "${BUYING_AGENT_API_BIND:-127.0.0.1}:${BUYING_AGENT_API_PORT:-8000}:8000" + depends_on: + postgres: + condition: service_healthy + healthcheck: + test: + [ + "CMD", + "python", + "-c", + "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health/ready')", + ] + interval: 10s + timeout: 5s + retries: 12 + start_period: 60s + restart: unless-stopped + networks: + - buyingagent-network + +volumes: + buyingagent-db-data: + +networks: + buyingagent-network: + driver: bridge diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md new file mode 100644 index 0000000..3f331a2 --- /dev/null +++ b/docs/DEPLOYMENT.md @@ -0,0 +1,87 @@ +# Deployment – BuyingAgent + +**Stand:** 2026-10-03 +**Server:** Raspberry Pi 5 (`192.168.2.49`) — gleicher Host wie Kanshō/Mitai/Shinkan/Kairo, **eigenes** Compose-Projekt +**Runner:** Gitea Actions (`/home/lars/gitea-runner/`, Label `ubuntu-latest` → Host) +**Repo:** `http://192.168.2.144:3000/Lars/BuyingAgent.git` + +Fachliche Grundlage: `.specs/buying-agent-v0.1-spec.md` bzw. `docs/buying-agent-v0.1-spec-v1.1.md`. +v0.1 hat keine Web-UI; öffentlich wird nur die API bereitgestellt. + +--- + +## Port- und Pfad-Übersicht + +| | Production | Development | +|---|------------|-------------| +| **Git-Branch** | `main` | `develop` | +| **Server-Verzeichnis** | `/home/lars/docker/buyingagent` | `/home/lars/docker/buyingagent-dev` | +| **API-Port** | 8006 | 8095 | +| **PostgreSQL** | nur Compose-Netz, DB `buyingagent` | nur Compose-Netz, DB `buyingagent_dev` | +| **Compose** | `docker-compose.yml` | `docker-compose.dev-env.yml` | +| **Health** | `http://localhost:8006/health/ready` | `http://localhost:8095/health/ready` | +| **LAN** | `http://192.168.2.49:8006` | `http://192.168.2.49:8095` | + +Lokal ohne Server: `127.0.0.1:8000` über `docker compose up --build -d` (siehe README). +8006/8095 waren auf dem Pi frei. 8000 ist durch Paperless belegt und darf auf dem Server nicht verwendet werden. + +--- + +## Branch-Regel + +- Entwicklung nur auf `develop`. +- Push auf `develop` startet den Dev-Build auf dem Pi. +- Produktiv nur über Merge-Commit `develop` → `main`: + +```bash +git checkout main +git pull origin main +git merge --no-ff develop +git push origin main +``` + +Kein Fast-Forward, kein direkter Commit auf `main`, kein manuelles `git pull` auf dem Prod-Checkout. Gitea (`deploy-prod.yml`) ist der einzige Prod-Schreibpfad für Code. + +--- + +## Einmalige Server-Einrichtung + +Die Workflows legen das Verzeichnis an und klonen bei Bedarf selbst. Actions sind im Repo bereits aktiv. Derselbe Pi-Runner wie die Schwesterprodukte (`ubuntu-latest`). + +Nach dem ersten erfolgreichen Deploy `.env` prüfen und für Production ein eigenes `DB_PASSWORD` setzen. PostgreSQL übernimmt User/Passwort nur beim **ersten** Start des Volumes. + +```bash +mkdir -p /home/lars/docker/buyingagent /home/lars/docker/buyingagent-dev +``` + +Watchtower bleibt aus. + +--- + +## Gitea Actions + +| Workflow | Trigger | Zweck | +|----------|---------|--------| +| `deploy-dev.yml` | Push `develop` | Deploy nach `/home/lars/docker/buyingagent-dev`, Health `localhost:8095` | +| `deploy-prod.yml` | Push `main` | Deploy nach `/home/lars/docker/buyingagent`, Health `localhost:8006` | +| `test.yml` | nach Dev-Deploy bzw. Push `develop` | nicht-schreibender Smoke gegen `/health/live` und `/health/ready` | + +--- + +## Manuelles Deploy + +```bash +# Development +cd /home/lars/docker/buyingagent-dev +git fetch origin develop && git reset --hard origin/develop +docker compose -f docker-compose.dev-env.yml build --no-cache api +BUYING_AGENT_API_PORT=8095 docker compose -f docker-compose.dev-env.yml up -d --wait +curl -sf http://localhost:8095/health/ready + +# Production +cd /home/lars/docker/buyingagent +git fetch origin main && git reset --hard origin/main +BUYING_AGENT_API_BIND=0.0.0.0 BUYING_AGENT_API_PORT=8006 docker compose build --no-cache api +BUYING_AGENT_API_BIND=0.0.0.0 BUYING_AGENT_API_PORT=8006 docker compose up -d --wait +curl -sf http://localhost:8006/health/ready +``` diff --git a/docs/buying-agent-v0.1-spec-v1.1.md b/docs/buying-agent-v0.1-spec-v1.1.md new file mode 100644 index 0000000..d470842 --- /dev/null +++ b/docs/buying-agent-v0.1-spec-v1.1.md @@ -0,0 +1,671 @@ +# Buying Agent v0.1 – Implementierungsspezifikation + +Status: implementierungsbereit · Spezifikationsversion: 1.1 · Stand: 2026-10-03 + +## Änderungen gegenüber Spezifikationsversion 1.0 + +- Preference-Scoring erhält ein explizites `strategy`-Feld; v0.1 implementiert ausschließlich `numeric_linear`, ohne vorsorgliche Plugin-Infrastruktur. +- Produkt-Eignung und Angebots-Eignung werden getrennt als `product_eligibility` und `offer_eligibility` bewertet; `overall_eligibility` wird daraus abgeleitet. +- Die Preisbewertung wird konzeptionell von der Identität eines Offers entkoppelt: v0.1 nutzt weiterhin nur die Historie des konkreten Offers, spätere Scoring-Versionen dürfen jedoch Variant- oder Market-Level-Referenzserien konsumieren. +- `raw_value` wird bei JSONB als inhaltlich unverändert, nicht als bytegenau identisch beschrieben. +- Ein verbindlicher `Future Extension Contract` dokumentiert, welche späteren Erweiterungen v0.1 nicht verbauen darf, ohne sie bereits zu implementieren. + +## 1. Auftrag und Zielbild + +Implementiere ein eigenständig lauffähiges Backend für einen generischen Buying-/Deal-Agenten. Das System bewertet, wie gut eine konkrete Produktvariante zu einem Bedarfsprofil passt und wie attraktiv ein bestimmtes Angebot preislich ist. Es liefert nachvollziehbare Entscheidungen, keine automatischen Käufe. + +Notebooks sind der erste Anwendungsfall, nicht das Datenmodell. Weitere Kategorien wie GPU-Server, Monitore oder Kameras müssen durch neue Attribute und Profile ohne Änderungen an der Engine oder zusätzliche produktspezifische DB-Spalten möglich sein. + +Diese Datei ist die vollständige Arbeitsgrundlage. Ein Zugriff auf den ursprünglichen Chat ist nicht erforderlich. Die dort beschlossene Architektur wird beibehalten. Die hier definierten Formeln, Fehlerfälle, API-Verträge und Zeitregeln konkretisieren bislang offene Punkte als verbindliche v0.1-Entscheidungen. Die Beispielwerte sind synthetische Testdaten und keine Markt- oder Herstellerangaben. + +### Walking Skeleton + +Der MVP muss diesen vollständigen Ablauf tatsächlich ausführen und dauerhaft speichern: + +```text +Profil → Produkt → Variante + Attribute → Angebot → Preisbeobachtungen + → Constraints → ProductScore → PriceScore → DealScore + → Klassifikation + Begründungen → gespeicherte Evaluation → API-Antwort +``` + +Ein manuell erfasster Kandidat genügt für den ersten vertikalen Durchstich. Danach werden Grenzfälle und drei Beispielkandidaten ergänzt. Keine bloßen Interfaces ohne funktionsfähigen End-to-End-Pfad. + +### Explizit nicht enthalten + +- Keepa, Idealo, Geizhals oder andere externe Datenquellen und Scraper. +- n8n, Scheduler, Benachrichtigungen und Hintergrundjobs. +- LLM-Normalisierung oder LLM-Bewertung. +- Web-UI; generierte OpenAPI-/Swagger-Dokumentation ist erlaubt. +- Auto-Discovery, automatische Produktsuche oder automatische Zuordnung fremder Produkte. +- Checkout, automatische Käufe, Nutzerkonten, Mehrmandantenfähigkeit und öffentliche Bereitstellung. +- Wechselkursumrechnung, marktweite Preisprognosen, ML und Optimierung von Gewichten. + +Diese Funktionen dürfen später über Adapter und Services ergänzt werden. Keine vorsorglichen Frameworks, leeren Integrationen oder Infrastruktur dafür bauen. + +## 2. Stack und Architekturgrenzen + +Verbindlich: Python 3.13, FastAPI, Pydantic 2, SQLAlchemy 2, Alembic, PostgreSQL 17, pytest und Docker Compose. Verwende einen synchronen SQLAlchemy-Zugriff mit psycopg 3; ein asynchroner Stack ist für v0.1 nicht erforderlich. Abhängigkeiten mit kompatiblen Versionen und einem eingecheckten Lockfile reproduzierbar festlegen. + +Abhängigkeiten zeigen nach innen: + +```text +HTTP/API → Application Services → Domain / Constraint Engine / Scoring + ↓ + Repositories und Source-Adapter + ↓ + PostgreSQL +``` + +- `domain` und `scoring` importieren weder FastAPI noch SQLAlchemy oder konkrete Quellen. +- Die Engine ist eine reine Berechnung über explizite Eingaben. Kein Netzwerkzugriff, kein DB-Zugriff und kein implizites Lesen der Systemzeit. +- Services laden Daten, erzeugen einen konsistenten Snapshot, rufen die Engine auf und speichern das Ergebnis atomar. +- API-Schemas und ORM-Modelle sind getrennt von Domain-Modellen. Kleine Dataclasses oder Pydantic-Modelle sind für die Domain zulässig. +- Regeln liegen als validierte Daten vor; keine Ausführung von Python, SQL, `eval` oder Templates aus Profilen. +- n8n bleibt später ein externer Aufrufer. Die gesamte Buying Engine funktioniert ohne n8n. + +## 3. Fachliche Entitäten und Identität + +### Product + +Produktfamilie bzw. Modell, beispielsweise „Example Mobile 13“. Felder: UUID `id`, `category`, `brand`, `name`, optional `model`, `created_at`. Keine RAM-, SSD- oder Preisfelder. Kategorie ist ein normalisierter String, kein Notebook-spezifisches Enum. + +### ProductVariant + +Eine eindeutig beschriebene technische Konfiguration eines Products. Felder: UUID `id`, `product_id`, `name`, optionale Hersteller-SKU `manufacturer_sku`, optionale Kennungen `identifiers` als JSON-Objekt, `created_at`. + +RAM, Speicher, Farbe, Tastaturlayout und Ausstattung gehören zur Variante. Angebote unterschiedlicher Konfigurationen dürfen nicht zusammengeführt werden. In v0.1 verknüpft der Aufrufer Varianten explizit; keine heuristische Identitätsauflösung. Hersteller-SKUs sind nicht global eindeutig. Identische Namen sind kein Dublettenbeweis. + +### Offer + +Ein konkretes Händlerangebot für genau eine Variante. Felder: UUID `id`, `variant_id`, `source_id`, `external_id`, `seller`, optionale `url`, `market` (z. B. `DE`), `currency` (dreistelliger Großbuchstaben-Code), `condition` (`new`, `refurbished`, `used`), `created_at`. + +Eindeutigkeit: `(source_id, external_id)`. Unterschiedliche Verkäufer, Zustände, Märkte oder Lieferumfänge sind unterschiedliche Angebote. Technisch abweichende Bundles erfordern eine passende Variante. Diese Identitätsfelder bleiben nach Erstellung unverändert. Ein Offer enthält keinen unabhängig veränderbaren „aktuellen Preis“. + +### PriceObservation + +Append-only-Beobachtung eines Offers zu einem Zeitpunkt. Felder: UUID `id`, `offer_id`, `source_id`, `external_observation_id`, `observed_at`, `ingested_at`, `item_price`, nullable `shipping_price`, `currency`, `availability` (`in_stock`, `out_of_stock`, `unknown`), `raw_payload` als JSON, `confidence`. + +- Geld als `Decimal`/PostgreSQL `NUMERIC(12,2)`, nie binäre Floats. API-Geldwerte werden als Dezimalstrings übertragen. +- `item_price > 0`; bekannte Versandkosten `>= 0`; null bedeutet unbekannt, nicht kostenlos. +- Gesamtpreis = Bruttopreis inklusive bekannter obligatorischer Bestandteile + Versand. Nur direkt für jeden Käufer verfügbare Preise erfassen; keine Mitgliederrabatte, Finanzierung oder personalisierte Coupons modellieren. +- Die Währung muss der Offer-Währung entsprechen. `source_id` muss der Offer-Quelle entsprechen. +- Zeitzonen sind obligatorisch; Speicherung und Ausgabe in UTC. Zeitpunkte in der Zukunft relativ zur Ingestion werden abgewiesen. +- Eindeutigkeit `(offer_id, external_observation_id)`: identischer Wiederholungsimport liefert den vorhandenen Datensatz, widersprüchlicher Inhalt HTTP 409. `ingested_at` ist serverseitig und zählt beim Inhaltsvergleich nicht mit. +- Beobachtungen werden nicht überschrieben. Korrekturen werden als neue Beobachtung mit neuer externer ID erfasst; ein administrativer Korrekturworkflow ist nicht Bestandteil von v0.1. + +## 4. Generische Attribute und Normalisierung + +Attribute werden für v0.1 ausschließlich an Varianten erfasst. Es gibt keine implizite Vererbung von Product-Attributen. Eine spätere Erweiterung muss eine eigene, explizite Vorrangregel definieren. + +### Attributdefinition + +`attribute_definitions` enthält `key`, `value_type` (`number`, `string`, `boolean`, `string_list`), nullable `canonical_unit`, optionale `minimum`/`maximum` und `description`. Schlüssel sind stabile `snake_case`-Bezeichner. Neue Definitionen lassen sich per API ohne Migration anlegen. Bestehende Definitionen sind in v0.1 unveränderlich; geänderte Semantik erhält einen neuen Schlüssel. + +Beispieldefinitionen: + +| key | type | canonical_unit | Wertebereich | +|---|---|---|---| +| ram_gb | number | GB | >= 0 | +| storage_gb | number | GB | >= 0 | +| weight_kg | number | kg | > 0, im Eingabevalidator | +| battery_score | number | null | 0–100 | +| display_score | number | null | 0–100 | +| cpu_score | number | null | 0–100 | +| ports | string_list | null | beliebige Strings | + +`*_score`-Attribute sind ausdrücklich manuelle Einschätzungen auf einer dokumentierten 0–100-Skala; die Engine erfindet keine Benchmarks oder Messwerte. + +### Attributwert + +Jede Zeile in `variant_attributes` ist eine unveränderliche Aussage einer Quelle: + +```json +{ + "attribute": "ram_gb", + "raw_value": "24GB LPDDR5X RAM", + "normalized_value": 24, + "unit": "GB", + "normalization_status": "normalized", + "normalizer_version": "manual-v1", + "confidence": 1.0, + "source_id": "manual", + "source_reference": "demo/notebook-a/ram", + "observed_at": "2026-10-03T10:00:00Z" +} +``` + +Zusätzlich serverseitig: UUID `id`, `variant_id`, `ingested_at`. `raw_value` ist verpflichtend und bleibt inhaltlich unverändert erhalten; es darf ein JSON-Skalar oder -Objekt sein. Bei Speicherung als JSONB ist keine bytegenaue Erhaltung der ursprünglichen JSON-Serialisierung garantiert. Falls eine spätere Quelle den exakten Response-Body benötigt, muss dieser separat als Rohtext gespeichert werden; das ist nicht Bestandteil von v0.1. `normalized_value` ist ein typisierter JSON-Wert. Bei `normalization_status=unknown` ist er null; ein optionales `normalization_error` erklärt den fehlenden Wert. Eine erfolglose Normalisierung ist kein Zahlenwert 0. + +Manuelle Erfassung liefert Raw- und Normalized-Wert explizit. `ManualNormalizer` validiert Typ, Bereich und kanonische Einheit, analysiert aber keinen Freitext. Falsche Typen oder Einheiten werden mit 422 abgewiesen. Keine automatische Umwandlung von `"32"` zu 32 oder `true` zu 1. `confidence` liegt zwischen 0 und 1 und bezeichnet die Zuverlässigkeit der Aussage, nicht den Produktnutzen. Eine absichtlich unbekannte Aussage ist zulässig. + +Bei mehreren Aussagen zu demselben Schlüssel verwendet der Snapshot die jüngste nach `(observed_at, ingested_at, id)` absteigend, jeweils nur bis zum Auswertungszeitpunkt bzw. Snapshot-Cutoff. Anschließend wird deren Confidence geprüft. Ein neuer unsicherer oder unbekannter Wert darf nicht stillschweigend durch einen älteren günstigen Wert ersetzt werden. Vorrang verschiedener Quellen ist damit zunächst bewusst einfach; spätere Auswahlstrategien benötigen eine neue Regelversion. + +## 5. Product Profile und Versionierung + +Ein Profil beschreibt Kategorie, Budget, harte Mindestanforderungen und gewichtete Präferenzen. Es besitzt eine stabile String-ID und eine positive Integer-Version. Jede `(id, version)` ist unveränderlich. Eine Änderung wird als neue Version gespeichert. Evaluationen müssen eine konkrete Version angeben; kein implizites „latest“. + +Vollständiges Beispiel für `profiles/mobile-development-notebook.json`: + +```json +{ + "id": "mobile-development-notebook", + "version": 1, + "name": "Mobiler Entwicklungsrechner", + "category": "notebook", + "currency": "EUR", + "market": "DE", + "allowed_conditions": ["new"], + "budget": {"target": "1500.00", "maximum": "2000.00"}, + "scoring_version": "generic-v1", + "constraints": [ + {"id": "min_ram", "attribute": "ram_gb", "operator": ">=", "value": 16, "mandatory": true}, + {"id": "min_storage", "attribute": "storage_gb", "operator": ">=", "value": 512, "mandatory": true}, + {"id": "max_weight", "attribute": "weight_kg", "operator": "<=", "value": 1.6, "mandatory": true} + ], + "preferences": [ + {"id": "ram", "attribute": "ram_gb", "strategy": "numeric_linear", "direction": "higher", "acceptable": 16, "ideal": 32, "weight": 0.30}, + {"id": "weight", "attribute": "weight_kg", "strategy": "numeric_linear", "direction": "lower", "acceptable": 1.6, "ideal": 1.0, "weight": 0.25}, + {"id": "battery", "attribute": "battery_score", "strategy": "numeric_linear", "direction": "higher", "acceptable": 60, "ideal": 100, "weight": 0.20}, + {"id": "display", "attribute": "display_score", "strategy": "numeric_linear", "direction": "higher", "acceptable": 60, "ideal": 100, "weight": 0.15}, + {"id": "cpu", "attribute": "cpu_score", "strategy": "numeric_linear", "direction": "higher", "acceptable": 60, "ideal": 100, "weight": 0.10} + ] +} +``` + +Jede Präferenz besitzt eine explizite `strategy`. v0.1 unterstützt ausschließlich `numeric_linear`. Das Profilformat ist jedoch so zu modellieren, dass spätere Scoring-Versionen zusätzliche klar benannte Strategien wie `boolean_preferred`, `categorical_match`, `set_contains` oder `categorical_rank` einführen können, ohne die Grundstruktur eines Profils zu ändern. Für v0.1 wird dafür weder ein Plugin-System noch dynamische Code-Ausführung gebaut; unbekannte Strategien werden bei der Profilvalidierung abgewiesen. + +Validierung: `0 < target < maximum`, mindestens eine Präferenz, positive Gewichte, innerhalb der jeweiligen Liste eindeutige IDs, bekannte Attributschlüssel und eine vorhandene Scoring-Version. Die Summe der Gewichte muss nicht 1 sein, weil die Engine normalisiert. Für `numeric_linear` gilt: `higher` verlangt `ideal > acceptable`, `lower` verlangt `ideal < acceptable`, und das referenzierte Attribut muss numerisch sein. Kategorie, Währung, Markt und erlaubte Zustände sind verpflichtend. + +Das Profil ist ein Beispiel aus der Architekturentscheidung, keine abschließend optimierte persönliche Kaufberatung. Die Auswahl eines Notebooks für mobile Entwicklung wird nicht mit Anforderungen an einen lokalen LLM-Server vermischt. + +## 6. Constraint Engine und Eignungsstatus + +Unterstütze ausschließlich folgende Operatoren; verwerfe ungültige Kombinationen bereits beim Anlegen des Profils: + +| Operator | Attributtyp | Vergleichswert / Semantik | +|---|---|---| +| `>=`, `<=`, `>`, `<` | number | Zahl in kanonischer Einheit | +| `==`, `!=` | number, string, boolean | gleicher skalarer Typ, exakter Vergleich | +| `in` | number, string, boolean | nichtleere Liste gleichartiger skalarer Werte | +| `contains` | string_list | einzelner String, exakte Mitgliedschaft | +| `exists` | alle | kein `value`; verwertbarer normalisierter Wert vorhanden | + +Keine impliziten Typkonvertierungen, String-Teilstrings oder case-insensitive Vergleiche. Leere String-Listen sind vorhandene Werte. Fehlend, null, nicht normalisiert oder Confidence unter der Regelgrenze ergibt `UNKNOWN`, auch bei `exists` und `!=`. + +Jedes Ergebnis enthält Constraint-ID, Attribut, Operator, Sollwert, Istwert, Status `PASS|FAIL|UNKNOWN`, Mandatory-Flag, gewählte Attributzeilen-ID und Reason-Code. + +### Product Eligibility + +`product_eligibility` beschreibt ausschließlich, ob die konkrete Produktvariante fachlich zum Profil passt. Dazu gehören: + +- Kategorie des Produkts/der Variante, +- alle mandatory Produkt-Constraints, +- technische bzw. produktbezogene Daten, die für diese Constraints benötigt werden. + +Ableitung: + +- mindestens ein bekannter produktbezogener harter `FAIL` → `product_eligibility=INELIGIBLE`; +- sonst mindestens ein mandatory produktbezogener `UNKNOWN` → `product_eligibility=UNKNOWN`; +- sonst → `product_eligibility=ELIGIBLE`. + +Optionale Constraints ändern `product_eligibility` nicht. Optionales `FAIL` oder `UNKNOWN` reduziert ausschließlich den ProductScore um die im Scoring-Modell konfigurierte Punktzahl und bleibt in den Ergebnissen sichtbar. + +Der ProductScore darf unabhängig von der aktuellen Angebotslage berechnet und als Diagnostik gespeichert werden, sofern das Profil und die vorhandenen Präferenzdaten dies zulassen. Ein technisch sehr gutes Produkt verliert seinen ProductScore nicht nur deshalb, weil das betrachtete Offer aktuell zu teuer oder nicht lieferbar ist. + +### Offer Eligibility + +`offer_eligibility` beschreibt ausschließlich die Eignung des konkret ausgewerteten Offers. Systemseitige Offer-Gates sind: + +- Markt entspricht dem Profil, +- Zustand ist in `allowed_conditions`, +- Währung entspricht der Profilwährung, +- aktuelle Preisbeobachtung ist vorhanden und frisch, +- Preis-Confidence erfüllt die Mindestschwelle, +- obligatorische Versandkosten sind bekannt, +- Gesamtpreis <= Budgetmaximum, +- aktuelle Verfügbarkeit ist `in_stock`. + +Ein bekannter Verstoß ergibt `offer_eligibility=INELIGIBLE`. Fehlende/zu alte Preisbeobachtung, geringe Preis-Confidence, unbekannte Versandkosten oder unbekannte Verfügbarkeit ergeben `offer_eligibility=UNKNOWN`. `out_of_stock` ergibt `INELIGIBLE`. Ein Preis oberhalb des Budgets ist ein Angebotsproblem und macht nicht das Produkt selbst ungeeignet. + +### Overall Eligibility + +`overall_eligibility` wird ausschließlich aus Product und Offer Eligibility abgeleitet: + +- wenn mindestens einer der beiden Status `INELIGIBLE` ist → `overall_eligibility=INELIGIBLE`; +- sonst wenn mindestens einer `UNKNOWN` ist → `overall_eligibility=UNKNOWN`; +- sonst → `overall_eligibility=ELIGIBLE`. + +Alle Gates werden ausgewertet, damit die Antwort sämtliche Gründe enthält. Ein bekannter harter Fehler hat innerhalb der jeweiligen Ebene Vorrang vor unbekannten Daten. Das API-Feld `eligible` ist eine Convenience-Abbildung von `overall_eligibility`: `ELIGIBLE` → true, `INELIGIBLE` → false, `UNKNOWN` → null. + +## 7. Scoring-Modell `generic-v1` + +Alle Scores liegen zwischen 0 und 100. Rechne intern mit Decimal, runde nur endgültige Scores und angezeigte Komponenten auf zwei Dezimalstellen, `ROUND_HALF_UP`. Klassifikation anhand des ungerundeten DealScore. Der Snapshot enthält auch ungerundete Dezimalstrings für eine eindeutige Grenzfallprüfung. + +Die unveränderliche Modelldefinition enthält mindestens: + +```json +{ + "version": "generic-v1", + "algorithm_id": "linear-budget-history-v1", + "supported_preference_strategies": ["numeric_linear"], + "minimum_confidence": 0.8, + "maximum_price_age_hours": 48, + "optional_constraint_penalty_points": 5, + "missing_preference_score": 0, + "product_weight": 0.6, + "price_weight": 0.4, + "budget_weight": 0.4, + "history_weight": 0.6, + "history_window_days": 90, + "minimum_history_days": 3, + "history_discount_for_max_score": 0.2, + "classification_upper_bounds": [ + {"upper": 60, "inclusive": false, "label": "IGNORE"}, + {"upper": 75, "inclusive": false, "label": "WATCH"}, + {"upper": 85, "inclusive": false, "label": "GOOD"}, + {"upper": 92, "inclusive": true, "label": "VERY_GOOD"}, + {"upper": 100, "inclusive": true, "label": "EXCEPTIONAL"} + ] +} +``` + +`clamp(x)=min(100,max(0,x))`. Modellvalidierung prüft positive Zeitfenster, gültige Confidence, nichtnegative Gewichte mit jeweiliger Summe 1, aufsteigende lückenlose Klassifikationsgrenzen, unterstützte Algorithmus-ID und eine nichtleere Liste unterstützter Preference-Strategien. Die Definition wird als kanonisches JSON inklusive SHA-256 gespeichert. Eine Version darf niemals mit geändertem Inhalt wiederverwendet werden. Neue Algorithmen oder Preference-Strategien werden als Code ergänzt und unter einer neuen Scoring-Version bzw. Algorithmus-ID registriert; JSON darf keinen ausführbaren Code enthalten. + +### ProductScore + +v0.1 implementiert ausschließlich die Preference-Strategie `numeric_linear`. Für eine solche numerische Präferenz mit Istwert `x`, akzeptablem Wert `a` und Idealwert `i`: + +```text +higher: component = clamp(50 + 50 × (x − a) / (i − a)) +lower: component = clamp(50 + 50 × (a − x) / (a − i)) +base_product_score = Σ(weight × component) / Σ(weight) +product_score = clamp(base_product_score − 5 × Anzahl optionaler FAIL/UNKNOWN) +``` + +Damit erhält ein akzeptabler Wert 50, ein idealer oder besserer Wert maximal 100. Ein fehlender oder unsicherer Präferenzwert erhält 0; sein Gewicht bleibt im Nenner. Fehlende Daten dürfen den Score nicht verbessern. `product_coverage` ist die Summe der Gewichte verwertbarer Präferenzen geteilt durch die Summe aller Gewichte. Confidence wird nicht nochmals als Score-Multiplikator verwendet. + +Der ProductScore beschreibt Produkt-Fit und wird unabhängig von `offer_eligibility` berechnet. Auch bei `product_eligibility=INELIGIBLE` oder `UNKNOWN` darf er diagnostisch ausgegeben werden; Mandatory Gates bleiben jedoch autoritativ und können niemals durch einen hohen ProductScore überstimmt werden. + +Die Strategy-Auswahl erfolgt explizit über das Preference-Feld `strategy`; es gibt keine dynamische Plugin-Suche. Unbekannte Strategien sind für `generic-v1` ein Validierungsfehler. + +### Auswahl des aktuellen Preises und der Referenzhistorie + +Der Evaluation-Request bezeichnet genau ein Offer. Kein implizites billigstes Angebot und kein Vermischen mehrerer Händler oder Varianten. + +v0.1 verwendet als `reference_history` ausschließlich Preisbeobachtungen dieses konkreten Offers. Diese Bindung ist eine Entscheidung der Scoring-Version, nicht des generischen Preis-Scoring-Vertrags. Die reine Preis-Scoring-Funktion ist daher konzeptionell als Berechnung über `current_price`, `reference_history` und `budget` zu implementieren; sie darf fachlich nicht verlangen, dass die Referenzserie dauerhaft ein Offer-Objekt ist. Spätere Scoring-Versionen dürfen Variant-Level- oder Market-Level-Referenzserien bereitstellen, ohne Product, ProductVariant, Offer oder PriceObservation neu zu modellieren. + +Für v0.1 gilt für die offer-bezogene Referenzserie: + +1. Betrachte ausschließlich Beobachtungen dieses Offers mit `observed_at <= as_of` und `ingested_at <= snapshot_cutoff`. +2. Aktuell ist die jüngste Zeile nach `(observed_at, ingested_at, id)` absteigend. Auch eine nicht verfügbare oder unsichere jüngste Zeile darf nicht durch eine ältere günstigere ersetzt werden. +3. Frische: `as_of − observed_at <= 48 Stunden`, einschließlich Grenze. +4. Historie endet strikt vor dem UTC-Kalendertag der aktuellen Beobachtung. Dadurch wird der aktuelle Angebotspreis nicht selbst zur historischen Referenz. +5. Für jedes Fenster N ∈ {30,90,365} gilt `[current_day − N Tage, current_day)`. Wähle zunächst pro UTC-Tag die jüngste Zeile, danach filtere Tage mit `in_stock`, bekannter Gesamtsumme und Confidence >= 0.8. So verdrängt eine spätere unbekannte Beobachtung eine frühere bekannte desselben Tages. +6. Berechne `median_30d`, `median_90d`, `lowest_365d` aus diesen Tageswerten. Bei gerader Anzahl ist der Median das arithmetische Mittel der beiden mittleren Werte. Gib die Zahl der vorhandenen Tage pro Fenster mit aus; keine Interpolation fehlender Tage. + +Historienaggregate werden berechnet und im Evaluation-Snapshot gespeichert, nicht als vom Client beliebig vorgegebene Wahrheit übernommen. Die v0.1-Historie ist angebotsbezogen und keine Aussage über den Gesamtmarkt. `lowest_365d` darf als Minimum weniger beobachteter Tage angezeigt werden, muss dann ausdrücklich als Minimum der vorhandenen Daten bezeichnet sein. Der Snapshot speichert zusätzlich `price_reference_scope="offer"` und die IDs der verwendeten Beobachtungen, damit spätere Scoring-Versionen andere Scopes eindeutig unterscheiden können. + +### PriceScore + +Seien `p` aktueller Gesamtpreis, `t` Budgetziel, `m` Budgetmaximum, `r` 90-Tage-Median der von der Scoring-Version bereitgestellten Referenzhistorie: + +```text +budget_score = 100 falls p <= t +budget_score = clamp(100 × (m − p) / (m − t)) sonst +discount = (r − p) / r +history_score = clamp(50 + 50 × discount / 0.20) +price_score = 0.40 × budget_score + 0.60 × history_score +``` + +Historien-Score: beim Median 50, bei 20 % günstiger 100, bei 20 % teurer 0. Nur bei mindestens drei verfügbaren Tagen im 90-Tage-Fenster ist `history_score` vorhanden. Andernfalls gilt `price_score=budget_score`, `price_basis=BUDGET_ONLY`, `history_score=null` und ein expliziter Hinweis `INSUFFICIENT_HISTORY`. Bei ausreichender Historie `price_basis=BUDGET_AND_HISTORY`. Kein Bonus für ein vermeintliches Jahrestief; 30-Tage-Median und Minimum dienen zunächst nur der Erklärung. + +Ohne frischen, hinreichend sicheren und vollständig bekannten Gesamtpreis in Profilwährung ist der PriceScore null. Ein bekannter Preis oberhalb des Budgets kann diagnostisch bewertet werden, während `offer_eligibility` wegen des Budget-Gates `INELIGIBLE` bleibt. Bei falscher Währung wird ohne Wechselkursumrechnung kein PriceScore berechnet. Ausverkaufte Angebote dürfen ihren bekannten Preis diagnostisch behalten; `offer_eligibility` bleibt dennoch `INELIGIBLE`. + +### DealScore und Klassifikation + +Nur bei `overall_eligibility=ELIGIBLE` und vorhandenem PriceScore: + +```text +deal_score = 0.60 × product_score + 0.40 × price_score +``` + +| Bereich des ungerundeten DealScore | Klassifikation | +|---|---| +| < 60 | IGNORE | +| >= 60 und < 75 | WATCH | +| >= 75 und < 85 | GOOD | +| >= 85 und <= 92 | VERY_GOOD | +| > 92 | EXCEPTIONAL | + +Bei `overall_eligibility=INELIGIBLE`: DealScore null, Klassifikation `INELIGIBLE`. Bei `overall_eligibility=UNKNOWN`: DealScore null, Klassifikation `INSUFFICIENT_DATA`. ProductScore und vorhandener PriceScore dürfen als Diagnostik ausgegeben werden, ersetzen aber niemals die Gate-Ergebnisse. Die getrennten Product-/Offer-Status müssen in Snapshot und API sichtbar bleiben, damit ein gutes Produkt mit momentan schlechtem Angebot weiterhin beobachtbar bleibt. + +### Nachvollziehbarkeit und Wiederholbarkeit + +Jede Evaluation speichert Profil und Version, Modellversion und Hash, Engine-Build-ID, `as_of`, `snapshot_cutoff`, `product_eligibility`, `offer_eligibility`, `overall_eligibility`, vollständige tatsächlich verwendete Eingaben und IDs sowie alle Komponenten, Gates, Aggregate, Coverage, Price-Reference-Scope, Reason-Codes und verständliche Texte. Persistierte Evaluationen sind unveränderlich. Ein erneuter Request erzeugt eine neue Evaluation; ein GET berechnet nichts neu. + +Historische Abfragen mit `as_of` rekonstruieren nur den Datenstand zum aktuellen Snapshot-Cutoff, einschließlich später eingegangener Rückdatierungen. Sie behaupten nicht, den damaligen Wissensstand wiederherzustellen. Exakte Wiederholung einer alten Bewertung erfolgt intern aus deren gespeichertem Snapshot und zugehöriger Algorithmusversion. Alte Algorithmen bleiben verfügbar; kein Replay-HTTP-Endpunkt für v0.1 erforderlich. + +## 8. Source-Adapter + +Implementiere einen kleinen `Protocol` und `ManualSource`. Quell-DTOs enthalten Rohdaten, Quellenkennung, externe IDs, Zeitpunkte und Confidence; sie enthalten keine ORM-Objekte und keine Scoring-Logik. + +```python +class ProductSource(Protocol): + source_key: str + def get_product(self, external_id: str) -> SourceProduct: ... + def get_offers(self, external_product_id: str) -> list[SourceOffer]: ... + def get_price_history(self, external_offer_id: str) -> list[SourcePriceObservation]: ... +``` + +`ManualSource` liest einen validierten lokalen Demo-Datensatz, keine externe Website. Ein Import-Service nimmt den Adapter entgegen und legt über dieselben Application Services wie die API Produkte, Varianten, Attribute, Angebote und Beobachtungen an. Explizite Variantenzuordnung aus der Fixture; keine Matching-Engine. Wiederholter Demo-Import erzeugt keine Duplikate: stabile UUIDs für Produkte/Varianten/Attribute, stabile externe Offer-/Beobachtungs-IDs und identischer Inhalt; Widersprüche melden statt überschreiben. + +Adapter normalisieren keine Preise oder technischen Eigenschaften im Scoring-Modul. Der Pfad lautet Quelle → validierte DTOs → Normalisierung → Persistenz → Evaluation. Für spätere Quellen genügen zunächst Adapter und Import-Mapping. Pagination, Rate Limits und Retry-Strategien erst zusammen mit der ersten realen Quelle einführen. + +## 9. HTTP-API + +Alle fachlichen Endpunkte unter `/api/v1`. UUIDs für Entitäten, String-IDs für Profile, Attribute und Scoring-Modelle. Pydantic-Schemas mit `extra=forbid`; Pflichtfelder und Enums im OpenAPI-Schema dokumentieren. JSON-Ausgabe nutzt `snake_case`. + +| Methode / Pfad | Vertrag | +|---|---| +| GET `/health/live` | Prozess lebt, kein DB-Zugriff | +| GET `/health/ready` | DB erreichbar und Migration auf Head; sonst 503 | +| POST `/api/v1/attribute-definitions` | unveränderliche Definition anlegen | +| GET `/api/v1/attribute-definitions` | Definitionen listen | +| GET `/api/v1/sources` | registrierte Quellen, einschließlich `manual` | +| GET `/api/v1/scoring-models/{version}` | vollständige Regeldefinition + Hash | +| POST `/api/v1/profiles` | vollständiges Profil inklusive Version anlegen | +| GET `/api/v1/profiles` | konkrete Profilversionen listen | +| GET `/api/v1/profiles/{id}?version=1` | konkrete Version; Query verpflichtend | +| POST `/api/v1/products` | Product anlegen | +| GET `/api/v1/products/{id}` | Product lesen | +| POST `/api/v1/products/{id}/variants` | Variante anlegen | +| GET `/api/v1/variants/{id}` | Variante mit Attributaussagen lesen | +| POST `/api/v1/variants/{id}/attributes` | einzelne unveränderliche Attributaussage hinzufügen | +| POST `/api/v1/variants/{id}/offers` | Offer anlegen | +| GET `/api/v1/offers/{id}` | Offer lesen | +| POST `/api/v1/offers/{id}/prices` | einzelne Beobachtung hinzufügen bzw. identisch wiederholen | +| GET `/api/v1/offers/{id}/prices` | gespeicherte Beobachtungen listen | +| POST `/api/v1/evaluate/{variant_id}` | auswerten und Ergebnis atomar speichern | +| GET `/api/v1/evaluations/{id}` | gespeichertes Ergebnis inklusive Snapshot lesen | + +Listen verwenden `limit` (Default 50, maximal 200) und `offset` (>=0); Antwort `{items, limit, offset, total}`. Stabile Sortierung nach Primärschlüssel, bei Preisen `(observed_at, ingested_at, id)` aufsteigend. Keine Update-/Delete-Endpunkte in v0.1. + +Evaluation-Request: + +```json +{ + "profile_id": "mobile-development-notebook", + "profile_version": 1, + "offer_id": "33333333-3333-4333-8333-333333333333", + "as_of": "2026-10-03T12:00:00Z" +} +``` + +`as_of` ist optional; der Service setzt es einmal auf Serverzeit UTC. Zukunftswerte sind ungültig. Offer muss zur angegebenen Variante gehören (422). Das Profil legt die Scoring-Version fest; kein stiller Override. Fachlich unpassende Kategorie wird im Product-Gate gespeichert; Markt, Zustand, Währung, Preis und Verfügbarkeit werden im Offer-Gate gespeichert. Keiner dieser fachlichen Fälle ist ein HTTP-Fehler. + +Evaluation-Response enthält mindestens: + +```json +{ + "id": "44444444-4444-4444-8444-444444444444", + "profile_id": "mobile-development-notebook", + "profile_version": 1, + "scoring_version": "generic-v1", + "product_eligibility": "ELIGIBLE", + "offer_eligibility": "ELIGIBLE", + "overall_eligibility": "ELIGIBLE", + "eligible": true, + "product_score": 90.21, + "price_score": 98.24, + "deal_score": 93.42, + "classification": "EXCEPTIONAL", + "price_basis": "BUDGET_AND_HISTORY", + "product_coverage": 1.0, + "failed_constraints": [], + "unknown_constraints": [], + "reasons": [ + {"code": "PRICE_BELOW_MEDIAN", "message": "Gesamtpreis 18,83 % unter dem Median der beobachteten 90-Tage-Historie."} + ] +} +``` + +Zusätzlich verpflichtend: `variant_id`, `offer_id`, `as_of`, `snapshot_cutoff`, `scoring_hash`, `engine_build`, `product_eligibility`, `offer_eligibility`, `overall_eligibility`, `components`, sämtliche `constraint_results` und Offer-Gates, `price_statistics`, `price_reference_scope`, `input_snapshot` und `warnings`. Die Kurzantwort oben illustriert nur die Kernfelder. `failed_constraints` enthält alle `FAIL`-Ergebnisse; `unknown_constraints` alle `UNKNOWN`-Ergebnisse. Reason-Codes sind stabil, Texte dienen Menschen und dürfen nicht als Programmierschnittstelle ausgewertet werden. + +POST liefert 201, identisch wiederholte Preisbeobachtung 200; GET liefert 200. Fehlende IDs: 404. Verletzte Eindeutigkeit/abweichender Wiederholungsinhalt: 409. Ungültige Payload/Referenzkombination: 422. Einheitliches Fehlerformat `{error: {code, message, details}}`, auch für Request-Validierung. Keine SQL-Details an Clients. Eine fachlich negative Evaluation ist eine erfolgreiche 201-Antwort. + +## 10. Datenbank und Transaktionen + +Alle Zeitstempel `TIMESTAMPTZ`; JSON-Daten `JSONB`; UUID-Primärschlüssel für Entitäten; Decimal-Geldfelder. `created_at`/`ingested_at` werden serverseitig gesetzt. Fremdschlüssel sind obligatorisch. Kein Cascade-Delete historischer Bewertungen oder Quelldaten. + +| Tabelle | Wesentliche Spalten und Constraints | +|---|---| +| `attribute_definitions` | `key` PK, Typ, Einheit, Bereich, Beschreibung | +| `sources` | `id` String-PK, `name`, `adapter_type`; Seed `manual` | +| `scoring_models` | `version` PK, `algorithm_id`, `definition` JSONB, `definition_hash`, `created_at` | +| `product_profiles` | zusammengesetzter PK `(id, version)`, `definition` JSONB, `category`, FK `scoring_version`, `definition_hash`, `created_at` | +| `products` | `id`, Kategorie, Marke, Name, Modell, `created_at` | +| `product_variants` | `id`, FK Product, Name, Hersteller-SKU, Kennungen JSONB, `created_at` | +| `variant_attributes` | `id`, FK Variante/Definition/Quelle, Raw/Normalized JSONB, Einheit, Status, Normalizer-Version, Confidence, Source-Referenz, Beobachtungs-/Ingestion-Zeit, Fehler | +| `offers` | `id`, FK Variante/Quelle, externe ID, Händler, URL, Markt, Währung, Zustand; UNIQUE Quelle/externe ID | +| `price_observations` | `id`, FK Offer/Quelle, externe Beobachtungs-ID, Preise, Währung, Verfügbarkeit, Confidence, Raw JSONB, beide Zeiten; UNIQUE Offer/externe ID | +| `evaluations` | `id`, FK Variante/Offer/Profilversion/Scoring-Version, Zeitpunkte, Hash, Build-ID, `product_eligibility`, `offer_eligibility`, `overall_eligibility`, drei nullable Scores, Klassifikation, `price_reference_scope`, vollständige Eingabe-/Ergebnis-Snapshots JSONB | +| `evaluation_components` | `id`, FK Evaluation, `kind`, `component_key`, nullable Rohscore/Gewicht/Beitrag, `details` JSONB; UNIQUE Evaluation/kind/key | + +`evaluation_components` enthält Präferenzen inklusive Strategy, optionale Abzüge, Budget-/History-Komponenten, Product-Gates und Offer-Gates. JSON-Snapshots sind die vollständige Replay-Grundlage; relationale Komponenten dienen Abfragen und dürfen bei Erstellung nicht davon abweichen. + +DB-Checks mindestens für nichtnegative Preise, positiven Artikelpreis, Confidence 0–1, Scores 0–100, Profilversion >0 und gültige feste Enums. Typ-/Einheiten-/Profilvalidierung bleibt zusätzlich in der Application-Schicht. Redundante Indexspalten eines Profils müssen aus dessen validierter Definition erzeugt werden. + +Indizes: Attribute `(variant_id, attribute_key, observed_at DESC, ingested_at DESC, id DESC)`, Preise `(offer_id, observed_at DESC, ingested_at DESC, id DESC)`, Offers `variant_id`, Evaluationen `(profile_id, profile_version, created_at DESC)` und `variant_id`. + +Eine Evaluation liest und schreibt innerhalb einer PostgreSQL-Transaktion mit konsistentem Snapshot (`REPEATABLE READ`). Zu Beginn `snapshot_cutoff` festlegen; komplette Inputs, Ergebnis und Komponenten atomar committen. Bei Fehlern kein Teilergebnis. Insert-Konflikte über UNIQUE-Constraints behandeln, nicht allein per vorherigem SELECT. + +Alembic ist die einzige produktive Schemaanlage; kein `metadata.create_all()` beim API-Start. Initialmigration enthält Tabellen, Constraints und Indizes. Bootstrap von Quellen und Regeldefinitionen separat, idempotent und inhaltlich geprüft. Ein Konflikt gleicher Version mit anderem Hash stoppt den Bootstrap sichtbar. + +## 11. Repository und Lieferumfang + +```text +buying-agent/ +├── README.md +├── Dockerfile +├── docker-compose.yml +├── .dockerignore +├── .env.example +├── .gitignore +├── pyproject.toml +├── uv.lock +├── alembic.ini +├── migrations/ +│ ├── env.py +│ └── versions/ +├── app/ +│ ├── main.py +│ ├── settings.py +│ ├── api/ +│ │ ├── schemas.py +│ │ ├── profiles.py +│ │ ├── products.py +│ │ ├── offers.py +│ │ ├── evaluations.py +│ │ └── health.py +│ ├── domain/ +│ │ ├── models.py +│ │ ├── attributes.py +│ │ ├── constraints.py +│ │ └── enums.py +│ ├── scoring/ +│ │ ├── product.py +│ │ ├── price.py +│ │ ├── deal.py +│ │ └── engine.py +│ ├── normalization/ +│ │ ├── base.py +│ │ └── manual.py +│ ├── sources/ +│ │ ├── base.py +│ │ └── manual.py +│ ├── persistence/ +│ │ ├── db.py +│ │ ├── models.py +│ │ └── repositories.py +│ └── services/ +│ ├── ingestion_service.py +│ └── evaluation_service.py +├── rules/generic-v1.json +├── profiles/mobile-development-notebook.json +├── fixtures/manual-demo.json +├── scripts/ +│ ├── bootstrap.py +│ └── demo.py +└── tests/ + ├── unit/ + ├── integration/ + └── e2e/ +``` + +`__init__.py` nach Bedarf ergänzen. Kleine Dateien dürfen sinnvoll zusammengelegt werden; Architekturgrenzen sind verbindlich. Kein generisches Repository-Framework und kein Plugin-Discovery-System erforderlich. Das Lockfile-Beispiel verwendet uv; uv als Build-/Entwicklungswerkzeug mit dokumentierter Version verwenden. + +README enthält Setup, Architektur, Datenmodell, Formeln, Fehlersemantik, alle Start-/Testkommandos, vollständige Beispielrequests sowie Grenzen der synthetischen Preisbewertung. Diese Spezifikation ebenfalls im Repository unter `docs/buying-agent-v0.1-spec-v1.1.md` ablegen. + +## 12. Docker und lokale Ausführung + +Compose enthält nur zwei dauerhaft laufende Services: `api` und `postgres`. PostgreSQL 17 mit benanntem Datenvolume und Healthcheck. API basiert auf Python 3.13, läuft als Nicht-Root und bindet den veröffentlichten Port an `127.0.0.1:8000`. Die Datenbank braucht keinen veröffentlichten Host-Port. Secrets und lokale DB-Zugänge über `.env`, nur Platzhalter in `.env.example`. + +Für das lokale Einzelinstanz-Skeleton darf das API-Entrypoint nach erfolgreichem DB-Healthcheck sequenziell `alembic upgrade head`, Bootstrap und Uvicorn ausführen. Fehlgeschlagene Migrationen verhindern den API-Start. Diese Startmigration ist keine Strategie für spätere Mehrinstanz-Deployments. + +Verbindlicher Startpfad: + +```sh +cp .env.example .env +docker compose up --build -d +docker compose exec api python -m scripts.demo +``` + +Für PowerShell zusätzlich `Copy-Item .env.example .env` dokumentieren. `scripts.demo` legt Definitionen, Profil und synthetische Kandidaten über die API an, wiederholt sich ohne Duplikate und ruft Evaluationen mit explizitem `as_of` auf. Die Demo umfasst mindestens die Fälle A, B, C und D aus Abschnitt 13. Statische Fixtures nutzen den unten definierten historischen Zeitpunkt, damit sie nicht mit der Kalenderzeit altern. Der Adapterpfad wird separat im Integrationstest geprüft; die Demo muss den HTTP-Pfad abdecken. + +Testabhängigkeiten in einer Docker-Build-Stage `test` bereitstellen. README dokumentiert einen ausführbaren Compose-Test-Override oder gleichwertigen Befehl mit dieser Stage und separater Testdatenbank. Tests dürfen niemals das normale Datenvolume leeren. Kein dauerhafter dritter Service nötig. + +## 13. Deterministische Beispiele und Tests + +### Referenzfall A + +`as_of=2026-10-03T12:00:00Z`, aktuelle Beobachtung um 10:00 UTC; alle technischen Attribute vorher beobachtet. Kategorie Notebook, Markt DE, Zustand neu, EUR, lieferbar, Confidence jeweils 1.0, Versand 0.00. + +| Attribut | Wert | Präferenzscore | +|---|---:|---:| +| ram_gb | 32 | 100 | +| storage_gb | 1024 | nur Mindestconstraint | +| weight_kg | 1.2 | 83.333333… | +| battery_score | 90 | 87.5 | +| display_score | 90 | 87.5 | +| cpu_score | 90 | 87.5 | + +Gesamtpreise der Historie: 1599.00 am 2026-09-01, 1599.00 am 2026-09-15, 1599.00 am 2026-10-01; jeweils 10:00 UTC. Aktuell 1298.00. Alle Zeilen betreffen dasselbe Offer und sind lieferbar. Fixtures werden vor Evaluation vollständig importiert. + +Erwartungen: + +```text +product_eligibility = ELIGIBLE +offer_eligibility = ELIGIBLE +overall_eligibility = ELIGIBLE; eligible = true +product_score = 90.21 +median_30d = 1599.00 (2 Tage) +median_90d = 1599.00 (3 Tage) +lowest_365d = 1599.00 (3 Tage) +budget_score = 100 +history_score = 97.06 +price_score = 98.24 +deal_score = 93.42 +classification = EXCEPTIONAL +product_coverage = 1.0 +``` + +Die Engine rechnet mit den ungerundeten Zwischenwerten. Die Response aus Abschnitt 9 bezieht sich auf diesen Fall. Das beobachtete Minimum von 1599.00 ist ausdrücklich kein belegtes Jahrestief des Marktes. + +### Weitere Demokandidaten + +- B: identisch zu A, aber 8 GB RAM → `product_eligibility=INELIGIBLE`, `offer_eligibility=ELIGIBLE`, `overall_eligibility=INELIGIBLE`, Mandatory RAM `FAIL`, ProductScore weiterhin diagnostisch vorhanden, kein DealScore. +- C: identisch zu A, aber keine verwertbare Gewichtsangabe → `product_eligibility=UNKNOWN`, `offer_eligibility=ELIGIBLE`, `overall_eligibility=UNKNOWN`, Mandatory Gewicht `UNKNOWN`, ProductScore diagnostisch vorhanden, Klassifikation `INSUFFICIENT_DATA`, kein DealScore. +- D: technisch identisch zu A, aber aktueller Gesamtpreis 2049.00 bei Budgetmaximum 2000.00 → `product_eligibility=ELIGIBLE`, `offer_eligibility=INELIGIBLE`, `overall_eligibility=INELIGIBLE`; ProductScore und diagnostischer PriceScore bleiben vorhanden, DealScore null. Dieser Fall beweist die Trennung von Produkt-Fit und Angebots-Fit. + +### Unit-Tests + +- Jeden Constraint-Operator mit PASS, FAIL, UNKNOWN; Gleichheitsgrenzen, strikte Typen, leere Listen und Confidence exakt 0.8 bzw. knapp darunter. +- Preference-Strategie `numeric_linear`: akzeptabel 50, ideal 100, Clamping, lower/higher, nicht auf 1 summierte Gewichte, fehlende Werte und optionale Abzüge; unbekannte `strategy` wird für `generic-v1` abgewiesen. +- Budget bei Ziel/Maximum/darüber; History-Score bei −20 %, 0 % und +20 % Rabatt; BUDGET_ONLY bei 0–2 Historientagen. +- Tagesauswahl vor Qualitätsfilter, Fenstergrenzen, Ausschluss des aktuellen Tages, Median gerader/ungerader Anzahl, identische Zeitstempel und stabile Tie-Breaks. +- Keine Vermischung von Offers, Varianten, Währungen oder Zuständen; aktuelle Zeile unbekannt/ausverkauft ohne Rückfall auf ältere Zeilen; ProductScore bleibt von Offer-Gates unabhängig. +- Frische exakt 48 Stunden/knapp darüber; unbekannter Versand; fehlender Preis; Kombination Mandatory FAIL und UNKNOWN. +- Klassifikationsgrenzen 60, 75, 85, 92 und unmittelbar darüber; Rundung darf keine Klasse verschieben. +- Referenzfall A einschließlich exakter gerundeter Resultate; andere Kategorie mit eigenen Attributen ohne Engine-Änderung; Fall D zur Trennung von Product/Offer Eligibility. +- Preis-Scoring-Funktion wird mit explizitem `current_price`, `reference_history` und `budget` getestet; Domain-Test darf dafür kein Offer-ORM-Objekt benötigen. `price_reference_scope=offer` ist für v0.1 verpflichtend. +- Replay aus gespeichertem Input trotz später hinzugefügter Attribute/Preise liefert identische fachliche Ergebnisse; generierte IDs und Speicherzeit sind kein Bestandteil des Vergleichs. + +### Integration und End-to-End + +- Gegen echtes PostgreSQL 17, nicht SQLite als Ersatz für JSONB, Numeric, Constraints oder Isolation. +- Migration auf leerer Test-DB; erneutes Upgrade ohne Änderung; initialer Downgrade/Upgrade nur in wegwerfbarer Test-DB. +- Bootstrap identisch wiederholbar, veränderte Regel mit gleicher Version wird abgewiesen. +- CRUD-Anlage-/Lesepfade, validierte Profilreferenzen, 404/409/422, Pagination, exakte Decimal- und UTC-Roundtrips. +- Doppelte Preisimporte auch bei konkurrierenden Requests: eine Beobachtung, kein stiller Content-Overwrite. +- Gescheiterte Evaluation-Persistenz hinterlässt weder Evaluation noch Komponenten. +- HTTP-End-to-End: Profil → Produkt → Variante → Attribute → Offer → Historie → Evaluation → GET; danach API-Neustart und erneutes GET desselben unveränderten Ergebnisses. +- ManualSource-Import nutzt dieselben Services und erzeugt bei Wiederholung keine Duplikate. +- Einfügen neuer Preisbeobachtungen ändert alte Evaluationen nicht; neue Evaluation verwendet neue Inputs. + +## 14. Implementierungsreihenfolge + +1. Projekt, Settings, Docker, PostgreSQL und initiale Migration aufsetzen. +2. Domain-Modelle, Definitions-/Profilvalidierung einschließlich `strategy`, getrennte Product-/Offer-Gates und reine Constraint-/Scoring-Funktionen samt Referenztests implementieren. +3. Preis-Scoring als reine Berechnung über `current_price`, `reference_history` und `budget` implementieren; v0.1-Assembler liefert dafür ausschließlich offer-bezogene Historie und markiert `price_reference_scope=offer`. +4. Repositories und transaktionalen Evaluation-Service integrieren. +5. API, ManualSource und Bootstrap anschließen; ersten vollständigen Durchstich ausführen. +6. Demo, Integrationstests, Fehlerfälle und nachvollziehbare Responses vervollständigen. +7. README und sauberen Start auf leerem Volume prüfen; tatsächliche Testergebnisse dokumentieren. + +Routineentscheidungen innerhalb dieser Spezifikation selbst treffen. Falls eine Vorgabe technisch widersprüchlich ist, den konkreten Konflikt dokumentieren und minimal auflösen; keine stillen fachlichen Formeländerungen oder Scope-Erweiterungen. Insbesondere keine Infrastruktur für spätere Preference-Strategien, Variant-/Market-Level-Preisreihen, n8n, LLMs oder Discovery vorwegnehmen. + +## 15. Definition of Done + +- [ ] Ein frischer Checkout startet mit den dokumentierten Docker-Kommandos; Migration und Readiness funktionieren. +- [ ] PostgreSQL persistiert Daten über API-/Container-Neustarts hinweg. +- [ ] Alle vier Entitäten Product, ProductVariant, Offer, PriceObservation sind getrennt und korrekt verknüpft. +- [ ] Attribute sind generisch und bewahren Raw inhaltlich unverändert, Normalized, Quelle, Confidence und Normalizer-Version. +- [ ] Beispielprofil und `generic-v1` liegen versioniert als Dateien vor und werden validiert gespeichert; jede Preference besitzt `strategy`, v0.1 akzeptiert ausschließlich `numeric_linear`. +- [ ] Product-, Offer- und Overall-Eligibility werden getrennt berechnet, persistiert und über die API ausgegeben. +- [ ] Ein technisch passendes Produkt behält ProductScore/Product Eligibility unabhängig davon, ob das konkrete Offer aktuell zu teuer, ausverkauft oder anderweitig ungeeignet ist. +- [ ] Das Preis-Scoring ist fachlich über `current_price`, `reference_history` und `budget` entkoppelt; v0.1 verwendet ausschließlich `price_reference_scope=offer` und implementiert keine Variant-/Market-Level-Historie. +- [ ] Demo liefert für A, B, C und D die festgelegten Ergebnisse über die API. +- [ ] Scores, harte Gates, fehlende Daten, Historienbasis, Reference Scope und Begründungen sind sichtbar und reproduzierbar. +- [ ] Evaluationen enthalten vollständige unveränderliche Snapshots; spätere Daten und Regelversionen verändern sie nicht. +- [ ] Unit-, PostgreSQL-Integrations- und HTTP-End-to-End-Tests sind grün; Kommandos und Resultate sind dokumentiert. +- [ ] Neue Kategorie und neue Attribute funktionieren ohne Schema- oder Engine-Änderung, solange sie von den in der gewählten Scoring-Version unterstützten Strategies/Operatoren abgedeckt werden. +- [ ] Keine reale externe Quelle, kein n8n, keine LLM-Normalisierung, keine Web-UI, keine Auto-Discovery, keine Variant-/Market-Level-Preisaggregation und keine vorsorgliche Plugin-Infrastruktur wurden eingebaut. +- [ ] README ermöglicht einem anderen Entwickler Setup, Demo, Tests und Verständnis der Grenzen ohne Chat-Kontext. + +Abschlussbericht des Coding-Agenten: implementierter Umfang, relevante Entscheidungen, ausgeführte Prüfungen mit Ergebnissen und gegebenenfalls konkrete verbleibende Abweichungen. Nicht ausgeführte Tests ausdrücklich als solche benennen. + +## 16. Future Extension Contract + +v0.1 ist ein Walking Skeleton des Zielsystems. Die folgenden Fähigkeiten sind ausdrücklich **keine v0.1-Deliverables**, die Architektur darf ihre spätere Ergänzung jedoch nicht durch produktspezifische Schemata, fest verdrahtete Scoring-Annahmen oder n8n-interne Fachlogik unnötig blockieren: + +- mehrere externe Produkt- und Preisquellen, +- source-spezifische Search-/Discovery-Adapter, +- Variant-Level- und Market-Level-Preisreferenzserien, +- weitere Produktkategorien ohne produktspezifische DB-Spalten, +- weitere explizit versionierte Preference-Strategien wie `boolean_preferred`, `categorical_match`, `set_contains` oder `categorical_rank`, +- LLM-gestützte Normalisierung mit Raw-Wert, normalisiertem Wert, Confidence und Normalizer-Version, +- n8n für Scheduling, Orchestrierung und Benachrichtigungen, +- automatische Kandidatensuche und Kandidatenaufnahme, +- mehrere parallele Buying-Profile und Watchlists. + +Dieser Vertrag verlangt **keine vorsorgliche Infrastruktur** für diese Funktionen. Insbesondere keine Plugin-Registry, keinen Event-Bus, keine Microservices, keine zusätzlichen Preisaggregationstabellen, keine LLM-Abstraktionsschicht und keine n8n-Workflows bauen, solange sie nicht für den aktuellen Slice erforderlich sind. + +Die stabilen Erweiterungspunkte von v0.1 sind: + +1. Source-Adapter liefern validierte DTOs in den bestehenden Importpfad. +2. Attribute und Constraints bleiben generisch und datengetrieben. +3. Preference-Strategien werden explizit benannt und durch versionierte Scoring-Modelle unterstützt. +4. PriceScore konsumiert eine von der Scoring-Version bereitgestellte Referenzserie statt dauerhaft an Offer-Identität gekoppelt zu sein. +5. Product-, Offer- und Overall-Eligibility bleiben getrennt, damit Monitoring später gute Produkte unabhängig von momentan schlechten Angeboten verfolgen kann. +6. n8n bleibt externer Orchestrator; die Buying Engine bleibt ohne n8n vollständig nutzbar und testbar. + diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..92b3fbf --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,38 @@ +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool + +from app.settings import get_settings + +config = context.config +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +config.set_main_option("sqlalchemy.url", get_settings().database_url) +target_metadata = None + + +def run_migrations_offline() -> None: + url = config.get_main_option("sqlalchemy.url") + context.configure(url=url, target_metadata=target_metadata, literal_binds=True) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..e848d9e --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,25 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} +""" + +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +revision: str = ${repr(up_revision)} +down_revision: str | None = ${repr(down_revision)} +branch_labels: str | Sequence[str] | None = ${repr(branch_labels)} +depends_on: str | Sequence[str] | None = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/migrations/versions/0001_deploy_skeleton.py b/migrations/versions/0001_deploy_skeleton.py new file mode 100644 index 0000000..5f471f1 --- /dev/null +++ b/migrations/versions/0001_deploy_skeleton.py @@ -0,0 +1,21 @@ +"""Deploy skeleton — domain schema follows in a later revision. + +Revision ID: 0001_deploy_skeleton +Revises: +Create Date: 2026-10-03 +""" + +from collections.abc import Sequence + +revision: str = "0001_deploy_skeleton" +down_revision: str | None = None +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..8d560c0 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,32 @@ +[project] +name = "buying-agent" +version = "0.1.0" +description = "Generic buying and deal evaluation agent" +readme = "README.md" +requires-python = ">=3.13" +dependencies = [ + "alembic>=1.16", + "fastapi>=0.118", + "psycopg[binary]>=3.2", + "pydantic>=2.11", + "pydantic-settings>=2.11", + "sqlalchemy>=2.0", + "uvicorn[standard]>=0.37", +] + +[project.optional-dependencies] +test = [ + "httpx>=0.28", + "pytest>=8.4", +] + +[build-system] +requires = ["setuptools>=75"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +include = ["app*"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-ra" diff --git a/scripts/startup.sh b/scripts/startup.sh new file mode 100644 index 0000000..f9fb964 --- /dev/null +++ b/scripts/startup.sh @@ -0,0 +1,7 @@ +#!/bin/sh +set -e + +echo "=== BuyingAgent API startup ===" +alembic upgrade head +echo "=== starting uvicorn ===" +exec uvicorn app.main:app --host 0.0.0.0 --port 8000 diff --git a/tests/unit/test_health_live.py b/tests/unit/test_health_live.py new file mode 100644 index 0000000..406357b --- /dev/null +++ b/tests/unit/test_health_live.py @@ -0,0 +1,11 @@ +from fastapi.testclient import TestClient + +from app.main import app + +client = TestClient(app) + + +def test_health_live_does_not_touch_the_database() -> None: + response = client.get("/health/live") + assert response.status_code == 200 + assert response.json() == {"status": "ok"} diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..c9fb7d9 --- /dev/null +++ b/uv.lock @@ -0,0 +1,875 @@ +version = 1 +revision = 3 +requires-python = ">=3.13" + +[[package]] +name = "alembic" +version = "1.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ed/aa/02910bdb8e2f1444f6654d5b296cd827d126f82209050ee7b1000f92ac4b/alembic-1.20.0.tar.gz", hash = "sha256:db505480647bc60386c5369402f4a57a506b7539c9e9ef5e270d45cbbe4939bf", size = 2093272, upload-time = "2026-09-11T19:09:11.126Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/27/78a89b55b0904d222183164e079b4ca56208e94eff1d35ad1f1ad5be9b06/alembic-1.20.0-py3-none-any.whl", hash = "sha256:77eb101048d95f982c0353e9233404889dcd7a6fc244c107836c0e2fc9cf7d9d", size = 268719, upload-time = "2026-09-11T19:09:12.88Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "anyio" +version = "4.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, +] + +[[package]] +name = "buying-agent" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "alembic" }, + { name = "fastapi" }, + { name = "psycopg", extra = ["binary"] }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "sqlalchemy" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[package.optional-dependencies] +test = [ + { name = "httpx" }, + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "alembic", specifier = ">=1.16" }, + { name = "fastapi", specifier = ">=0.118" }, + { name = "httpx", marker = "extra == 'test'", specifier = ">=0.28" }, + { name = "psycopg", extras = ["binary"], specifier = ">=3.2" }, + { name = "pydantic", specifier = ">=2.11" }, + { name = "pydantic-settings", specifier = ">=2.11" }, + { name = "pytest", marker = "extra == 'test'", specifier = ">=8.4" }, + { name = "sqlalchemy", specifier = ">=2.0" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.37" }, +] +provides-extras = ["test"] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +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 = "fastapi" +version = "0.142.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "opentelemetry-api" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/56/4f/f7c30a73127e0a8bbffe788369b8359e530b01ae06e2757936fa35bc5e6d/fastapi-0.142.2.tar.gz", hash = "sha256:06366626f2e70576367714d9ab2fe8472e6c8456dba69b399f9f797ab5e92570", size = 466721, upload-time = "2026-09-30T09:51:11.277Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/b6/78aaf9141fb46742928c113f3cf6ef2259d538cb02b604b7656c1dc9883c/fastapi-0.142.2-py3-none-any.whl", hash = "sha256:bd5f4d81f1e93a88bcd77caf4dfe3c2dbffc3805407a0007e9a114c18b3a670b", size = 144409, upload-time = "2026-09-30T09:51:09.8Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httptools" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/e5/8cfcabc5546e8022f168be28bcdaa128a240a0befdd03b59d558b4f18bd6/httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8", size = 205148, upload-time = "2026-05-25T22:17:16.333Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c", size = 111368, upload-time = "2026-05-25T22:17:17.586Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7", size = 486447, upload-time = "2026-05-25T22:17:18.564Z" }, + { url = "https://files.pythonhosted.org/packages/77/00/258bfc0837221f81d9725c45f9b948a6a6b2994a147a4fb66e85100c668f/httptools-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d", size = 482448, upload-time = "2026-05-25T22:17:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/04/ab/d1cef3b5523f4d272a70f42a776c3169a2dddfe3a54de4b2ce4a36341528/httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681", size = 464460, upload-time = "2026-05-25T22:17:20.882Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/5d1d072442277bb2b3434e0e60690b8e8c23840ef7de8b6ea54040a536d3/httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683", size = 471312, upload-time = "2026-05-25T22:17:22.085Z" }, + { url = "https://files.pythonhosted.org/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1", size = 90117, upload-time = "2026-05-25T22:17:23.074Z" }, + { url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183, upload-time = "2026-05-25T22:17:24.004Z" }, + { url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079, upload-time = "2026-05-25T22:17:25.216Z" }, + { url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596, upload-time = "2026-05-25T22:17:26.186Z" }, + { url = "https://files.pythonhosted.org/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e", size = 480865, upload-time = "2026-05-25T22:17:27.542Z" }, + { url = "https://files.pythonhosted.org/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b", size = 463189, upload-time = "2026-05-25T22:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0", size = 466610, upload-time = "2026-05-25T22:17:29.816Z" }, + { url = "https://files.pythonhosted.org/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527", size = 92705, upload-time = "2026-05-25T22:17:31.133Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568", size = 215023, upload-time = "2026-05-25T22:17:32.401Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b", size = 117405, upload-time = "2026-05-25T22:17:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca", size = 558497, upload-time = "2026-05-25T22:17:34.732Z" }, + { url = "https://files.pythonhosted.org/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f", size = 571585, upload-time = "2026-05-25T22:17:35.813Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d", size = 543297, upload-time = "2026-05-25T22:17:37.08Z" }, + { url = "https://files.pythonhosted.org/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081", size = 539535, upload-time = "2026-05-25T22:17:38.032Z" }, + { url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +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 = "mako" +version = "1.4.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/09/e07c4b5579a79f4b16f8d4f29f6c54514ac787c4ad506b8c4f28a0e6b0bf/mako-1.4.3.tar.gz", hash = "sha256:cd6537fe88d5fec315c55c2f8529bc4ce7a9a352ad7db3eeaa6a66e2dd4ec37a", size = 412799, upload-time = "2026-09-22T20:54:31.509Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/a0/053d6af3e8f871e0073b4a36732d9e65be77a72e5434c31b94f6af78a6bb/mako-1.4.3-py3-none-any.whl", hash = "sha256:723296007c870bfd6b3f0c3230dba7198096e5269297ebf5e4eff9e7ffa39d4f", size = 80164, upload-time = "2026-09-22T20:54:33.128Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/38/9b/e422a865e1d5d57d0e509b4e0bf1c1a70a7f6382c29a5aa428df994c8bc8/markupsafe-3.0.4.tar.gz", hash = "sha256:2e9ad7dd851bf45fab9f75cbff4cb493fee9979e8d8c7c9c3ee119022518edd6", size = 153777, upload-time = "2026-10-02T23:07:22.29Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/18/4bc5ba32499e87bb2b0ef5b3a9bb9c00a131fa961ddf0be548cb550f548b/markupsafe-3.0.4-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:de8b364c423ef0a4bad9069657d617f9a5d2b2062457a89b1fa16ee199c399c1", size = 12786, upload-time = "2026-10-02T23:05:08.709Z" }, + { url = "https://files.pythonhosted.org/packages/4e/6f/17f0c099bf25f3e31e63cc19244d9f6af861a9a4ab778c203997903cfdd0/markupsafe-3.0.4-cp313-cp313-android_24_x86_64.whl", hash = "sha256:34bdde374c5932765d7dc685c4a1d191a3207852d67e8e0a9eb6ea85156181f1", size = 12510, upload-time = "2026-10-02T23:05:09.93Z" }, + { url = "https://files.pythonhosted.org/packages/11/af/1a141081b905036ee904ec4bd945e1f70b4e1b32d33c4e59e8cf1d58b247/markupsafe-3.0.4-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:6bd9e1788e15bfcf6a9082de42e30387e7b85d211ab21e57a939bb8cfaaf8d96", size = 11583, upload-time = "2026-10-02T23:05:10.884Z" }, + { url = "https://files.pythonhosted.org/packages/e7/0a/a89385ae590232622a03e091805cff12f24fabe6c11e0e8bae096cece81c/markupsafe-3.0.4-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:5066b244f576f91afc8ee3ba029a89f99d39c79b1853fe9d39bea9f0afbec148", size = 11801, upload-time = "2026-10-02T23:05:11.913Z" }, + { url = "https://files.pythonhosted.org/packages/ed/85/ea548dc013962eb73653124bc595635fbf9e0fa41d1f181a967ccb784dfb/markupsafe-3.0.4-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7a83aa6e4805df46fed18e989d3d16f86ef60cb50bbc8d9ce3a6be89165fbf6e", size = 11443, upload-time = "2026-10-02T23:05:12.887Z" }, + { url = "https://files.pythonhosted.org/packages/cc/72/15f2e5ec9cf2eb00d5cdfe968d94e4156a7bd7303832c3f3b2c403a36839/markupsafe-3.0.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2d1b7d9308288661f56672b1b157d75fc536714d3638487bbea17b6318a78248", size = 11665, upload-time = "2026-10-02T23:05:13.829Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e0/4030bea613677e333c8a2c901fd405055f657f9d06acba5b7357984b6ef7/markupsafe-3.0.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73e77980c7207854f00fc4e71fb1626868d5740ab4012623d55c7a99ad122a72", size = 12061, upload-time = "2026-10-02T23:05:14.807Z" }, + { url = "https://files.pythonhosted.org/packages/f3/a5/28b76a7449eb702966b88bef599e2360b411fbb3afeee8fe560939be06ec/markupsafe-3.0.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7018d4af1cd272e847aa5917983ab5e83e4f6579f9dbfecd4a79c0ca80b144c2", size = 24395, upload-time = "2026-10-02T23:05:15.909Z" }, + { url = "https://files.pythonhosted.org/packages/07/6c/21232811afc3a063b5e934b1ae2efda52f46154ec382f585149c020e61fe/markupsafe-3.0.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c90d5b3d4e944e065a301d741b3c1d784f6bd1f503aa68b4967e32b2ba313d85", size = 27183, upload-time = "2026-10-02T23:05:16.976Z" }, + { url = "https://files.pythonhosted.org/packages/14/38/6ccdfa5b59049cb36fb80cbc80aee9cf1fc9bb77d1335ad435f2070b08cf/markupsafe-3.0.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:18a801868a884f216e784d7d14db2a4077143ce7610440aee2ce8f734e7cfcde", size = 24772, upload-time = "2026-10-02T23:05:18.209Z" }, + { url = "https://files.pythonhosted.org/packages/63/e0/cec6865dfe88cb48fedd4b20aed6af5158e41092adcbf3e028bcc6ec2108/markupsafe-3.0.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:434139499bb20b502ed3baa1f169e618f924a97e7a777fea1a49446d80106cf6", size = 23017, upload-time = "2026-10-02T23:05:19.286Z" }, + { url = "https://files.pythonhosted.org/packages/ee/76/6ed4940bb7648a9aac457c14f870cfdd5105f139a0fb1f29cd61fafa47d1/markupsafe-3.0.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9e227f3dbe6bde7491cf0a9965d00b88c6b1a4a95d11480ddf88bb96d397c19f", size = 22036, upload-time = "2026-10-02T23:05:20.352Z" }, + { url = "https://files.pythonhosted.org/packages/a1/4f/ed476226d4fe46a09090a36025bf319296810028df55eb12f1253b540f3a/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b8cd1f918b26fd7b1832ece557cc18f2d8747309ff8b3f0ef9d4250c5ad67a39", size = 23786, upload-time = "2026-10-02T23:05:21.576Z" }, + { url = "https://files.pythonhosted.org/packages/9a/35/66ff30450e35ef5fba9ebc930c9411747e537fd9447b65e44f5007e2b84d/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a5fcffb37e602b0b3c1638a97746b9b96125caa9bcf6fa41d337a9261de231ee", size = 22672, upload-time = "2026-10-02T23:05:22.922Z" }, + { url = "https://files.pythonhosted.org/packages/32/0b/72f45ce4b4efcbca4b80cf1b06703eff0be8d37e82abb78f66c85a7ead1e/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5989cb26b2e1efc6a42216a9f6b5ee495ce5ace2e5b352a9af489976b32d1ee2", size = 24107, upload-time = "2026-10-02T23:05:24.175Z" }, + { url = "https://files.pythonhosted.org/packages/d2/03/71776e5fdcba04614b384cc102e8a4198208579d896fd1394cb7cb9aa900/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:add96447a86d205ab616665d53b2950ee81083757f56e6ea833c8b2917646b46", size = 21615, upload-time = "2026-10-02T23:05:25.215Z" }, + { url = "https://files.pythonhosted.org/packages/ab/5f/801ce02a02e7aee0f784b1ec7843026178f6adeb9c93ac67eb1992a9a84d/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2628d3a8cb648ecebb3c5d6b0a1052d400e4d8b7ac0fb786be8d285b50040d17", size = 23076, upload-time = "2026-10-02T23:05:26.423Z" }, + { url = "https://files.pythonhosted.org/packages/4a/85/c43776625428f3bb4a61e8633940400e3efe6409e3c6f5bff26de5e45618/markupsafe-3.0.4-cp313-cp313-win32.whl", hash = "sha256:672d207103e6b16ca098611b0f9efad6bc00afd47c03d6ef62186495ca677dc0", size = 14118, upload-time = "2026-10-02T23:05:27.716Z" }, + { url = "https://files.pythonhosted.org/packages/6f/36/163da64de88a13db79214ef75fa041be7fa13bdb42261cf5b7484de14bfb/markupsafe-3.0.4-cp313-cp313-win_amd64.whl", hash = "sha256:1f1f9477e174582b0a1b583d60b66e1f2cf5d3fe12cee985e4aedf44766600e5", size = 14313, upload-time = "2026-10-02T23:05:28.749Z" }, + { url = "https://files.pythonhosted.org/packages/9f/a8/9b662783ffaa1149221432a923cee562f78b9cbbb8baa3df9b3753e63e1e/markupsafe-3.0.4-cp313-cp313-win_arm64.whl", hash = "sha256:06de8ef6331f6e822c28d577dc8bf43fe398800477c49498f38fc38b67ff33fc", size = 14189, upload-time = "2026-10-02T23:05:29.917Z" }, + { url = "https://files.pythonhosted.org/packages/5c/c3/a944f3b0df22bd129e96915b9f4e98d2eeca6516687d7618304a966c3c74/markupsafe-3.0.4-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:4ed644d75aa94a2baf7ec3a96eaa160ea58c742eb9d27c6506053c5c40fc84ed", size = 12787, upload-time = "2026-10-02T23:05:30.971Z" }, + { url = "https://files.pythonhosted.org/packages/d4/d6/a44863f69d88b6c7e27889108f70d47aed259edf89d5df3c5fca1eac87d6/markupsafe-3.0.4-cp314-cp314-android_24_x86_64.whl", hash = "sha256:6d2a9efe686f9de00d0d1ea32a4a5a86d558a2277501bd78d964214eab625e59", size = 12525, upload-time = "2026-10-02T23:05:32.263Z" }, + { url = "https://files.pythonhosted.org/packages/17/8f/168ba80e532dd6a93f96f8f706f1ad41d7990b6e1aeedc1cc0d211a33497/markupsafe-3.0.4-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8781a792a070cf2bd1b86d3aa943894115faaba6e88122a7bf32d62072742453", size = 11589, upload-time = "2026-10-02T23:05:33.251Z" }, + { url = "https://files.pythonhosted.org/packages/32/b3/aa2c95a574d3af39403a469b295886eb9b6d448da568cbebb5a2cbfdc2e5/markupsafe-3.0.4-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:971a3bbb75d97ae4e2e8f7d4834236f86f85f0c85e04ab2e191db1123b04f80b", size = 11801, upload-time = "2026-10-02T23:05:34.315Z" }, + { url = "https://files.pythonhosted.org/packages/60/d0/34b810107d83840e768bf485de795893ebbae35b26ab061b487adfa0a692/markupsafe-3.0.4-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:8909c2f1c6dd65e054ac4b573a91c8384d1492281e55d82d159d653f7a13adf6", size = 11447, upload-time = "2026-10-02T23:05:35.302Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ab/2f8488f0f817a39fca068d2b17daf446bf5cdb3eae28c3720af534d873b4/markupsafe-3.0.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4cf3468d5ec187ffffcaca8e61929a37448f215dafc1386a12c750a72fe53634", size = 11722, upload-time = "2026-10-02T23:05:36.363Z" }, + { url = "https://files.pythonhosted.org/packages/ad/40/e2d117b048d47282ade906fbfd92814cbee5647afc13fda88a3406039372/markupsafe-3.0.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:52704c5d36eb6dda8866493decd61111fff86244c9b1ad225ca01b9e91e5970f", size = 12061, upload-time = "2026-10-02T23:05:37.397Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a8/73a81135e85ba66217f5af7facb03bbb386807e1a729ab64532e4c802652/markupsafe-3.0.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1caa2fa5a6184fb233153b35f654e6687bd555476f6170f29d8ee9be1a8b0af9", size = 24434, upload-time = "2026-10-02T23:05:38.407Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ca/fa9216dd01efee2dfdacafe7df32b4d0170fbac694b0c258a193d6e53999/markupsafe-3.0.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:387d8cd30e69b3f0a72877b9ae717033396404e19095b17fe89753a981fda44f", size = 27183, upload-time = "2026-10-02T23:05:39.581Z" }, + { url = "https://files.pythonhosted.org/packages/fa/4e/a469509e538d37af51103b17b073126973f2b1cbf197ff32c7ddf025cfe5/markupsafe-3.0.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:051417f74bcaaefa316276e0ff723f541616ca51043d070da00249d9bddd3e3c", size = 24813, upload-time = "2026-10-02T23:05:40.671Z" }, + { url = "https://files.pythonhosted.org/packages/8f/db/d7282caf7ab03af44d5d6fdbaa019b35c7d7f1c90588b839c07cba640d6a/markupsafe-3.0.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8e9f292fcda89b324f2f5c91d13f1424a153e40fc2756f38ee23b15835ff300", size = 23049, upload-time = "2026-10-02T23:05:41.864Z" }, + { url = "https://files.pythonhosted.org/packages/30/f3/b6a425206e6964efda6acee544d0eb01d1501784d0b8e2dcc74986f33b17/markupsafe-3.0.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:df1ae86ff54725a01fa1a0510b914ca53a161b7050be74f6204e24aded5971d0", size = 22088, upload-time = "2026-10-02T23:05:43.014Z" }, + { url = "https://files.pythonhosted.org/packages/ea/8a/84d3582fc1f0d5bd466cdf2eebf175e172158a6e70701aacec1de1b35430/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8965520ac587c94a4ac48b729be3d8b8de00af39699b17585dfb599babe77977", size = 23846, upload-time = "2026-10-02T23:05:44.098Z" }, + { url = "https://files.pythonhosted.org/packages/1c/65/db101cce51b7ba4864ac491a9859d297dd1adf0e55b103fee9db9c47c527/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:340cbb1957ba99929cbf19a75626d36ba1ae21d1730b287d1cf7f824a20c4fc7", size = 22674, upload-time = "2026-10-02T23:05:45.23Z" }, + { url = "https://files.pythonhosted.org/packages/e0/49/ddee9813d71db0c7a5c9d97c832125e6758a0c844777f1cf076569bb0e22/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:3a93d9616ddecfb393727a0041a562cf0b15a244e20f2bd25efc7949be4c4f17", size = 24104, upload-time = "2026-10-02T23:05:46.398Z" }, + { url = "https://files.pythonhosted.org/packages/aa/0e/7d8518d726726870a2399d69fd30d0fa36c5e57a2132c336b58d7c491073/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d2e56fd3b00222722abfb3f5f0759ddbae4b90811b5ad4343c64030ad1bde70c", size = 21637, upload-time = "2026-10-02T23:05:47.48Z" }, + { url = "https://files.pythonhosted.org/packages/b4/b0/b505e8a361ba557dbf3b3aa7331ea39b00d2022a26e925ff8463b9714bb3/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0d9c47709875fdb321452056622e930c52afbc07a7d780762fbb8b4d91ce6fa4", size = 23081, upload-time = "2026-10-02T23:05:48.611Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ea/9cc3cea873f980c75cbdb6f4277ce30ee955de38be0b3d02f14c108e0698/markupsafe-3.0.4-cp314-cp314-win32.whl", hash = "sha256:38fc55594dab834470b6733dead2ee9e3f657fb0608c769dcafa0ba5ab52f45c", size = 14272, upload-time = "2026-10-02T23:05:49.707Z" }, + { url = "https://files.pythonhosted.org/packages/80/f0/5792ff768a410f93ee3f84fc19345295ffc352d2c936b424cb37e514714c/markupsafe-3.0.4-cp314-cp314-win_amd64.whl", hash = "sha256:c1bc67752d5f21013cfe430df4062441714eab79f65a6a05e01505957e9c35fe", size = 14501, upload-time = "2026-10-02T23:05:50.788Z" }, + { url = "https://files.pythonhosted.org/packages/5f/cf/3d074a8edffcc6899355232ff2543ae8d929733239596423b7db79698bc9/markupsafe-3.0.4-cp314-cp314-win_arm64.whl", hash = "sha256:7e1636da3d8dfc220b6dd10264db5f2b165e4888c4518594898fbe381049af8a", size = 14364, upload-time = "2026-10-02T23:05:51.857Z" }, + { url = "https://files.pythonhosted.org/packages/d9/31/87ce42159aae2163cf3bbbd0c44bc87780510eecab1ea3859099aed95dcb/markupsafe-3.0.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:805c8b84534fa10891890f0e4be39f3a99e94615d93e8836bf9fa1fdca2feeb2", size = 11759, upload-time = "2026-10-02T23:05:52.951Z" }, + { url = "https://files.pythonhosted.org/packages/5f/53/b047207eeb7752e960aca3eb1df5fb7eefa7dd4c62ac49bb156456c8a702/markupsafe-3.0.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:fa95848c929b6a75f6848d3c9793e59db365ee436776e57db835cdbfa79ba977", size = 12096, upload-time = "2026-10-02T23:05:54.066Z" }, + { url = "https://files.pythonhosted.org/packages/ee/51/4326c88a13c7b755657d44b4bb986f8c3d9843ecba7e22d98661d87f9a57/markupsafe-3.0.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e916035e3e9930cbdfdd10abf48861340221857f45509565898e012263f7b289", size = 25699, upload-time = "2026-10-02T23:05:55.15Z" }, + { url = "https://files.pythonhosted.org/packages/f2/bb/990581b7474bfcf2cf34bed6ba5ea23bd87adb9d671213d68e88620e7a6b/markupsafe-3.0.4-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b4d12837e0203bbace818ff4a7461afdcd78bcd782351cea148139180d7bcffe", size = 27804, upload-time = "2026-10-02T23:05:56.29Z" }, + { url = "https://files.pythonhosted.org/packages/6b/89/89491878c28e8291f5aa2fffe2c2d57230d10ae366d55dd810b840513d78/markupsafe-3.0.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5086f9975abb1ab531ee6afca1761e4b59a19b446f3f6522ed776963228cfe5a", size = 25912, upload-time = "2026-10-02T23:05:57.416Z" }, + { url = "https://files.pythonhosted.org/packages/30/77/680998b54efdea06fc114565cd739b6d059f826a0279219b218dfa750d29/markupsafe-3.0.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b4a635a0487774f841cb1fb62e907e7195cc95bc761e053184b8acc3ceb20733", size = 23623, upload-time = "2026-10-02T23:05:58.557Z" }, + { url = "https://files.pythonhosted.org/packages/ae/75/2709f5ac5de9467b40b10e2bb8f89cc63dfb74582e09aa734b1124a217de/markupsafe-3.0.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb96e6e088d6cf71c1ea977510948320234824cf226e32f6f6e044f7a9c82b34", size = 23301, upload-time = "2026-10-02T23:05:59.94Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c8/39eadc6c5b14c9c7679bfb98f4d4c6a97863b5beb91839aca4d2d6e16e55/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8b5d563170ff8ba3181caa967c99a3c804d1dedb702c7cb93a6a7c32247da978", size = 24950, upload-time = "2026-10-02T23:06:01.289Z" }, + { url = "https://files.pythonhosted.org/packages/1a/5e/01037f8a43e8ccb0bffb4fbdc5212db05bf080fdd7286cd392332d58128a/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:396ec4e65cc889f69786b3b89478b471cee5a3bcf468b9d9bb03e1a30fb291fc", size = 23330, upload-time = "2026-10-02T23:06:02.441Z" }, + { url = "https://files.pythonhosted.org/packages/d4/f4/23e83ce0596bb0cbe670502d31df8f757bbd01a392aa486fa3b40d1ed399/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:15ba9e28640feef770374b116a6f019c21f52404aeabe516aa7f800587b98cfc", size = 25239, upload-time = "2026-10-02T23:06:03.579Z" }, + { url = "https://files.pythonhosted.org/packages/88/5b/3708897368073cc683d524750474f41a77d2986152c380dcc55b20fdf340/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d920abdfa61279ba1a2ef9484aab07bf03331f8c08a10120fa332353d06e6932", size = 22767, upload-time = "2026-10-02T23:06:04.699Z" }, + { url = "https://files.pythonhosted.org/packages/c6/61/ebda1307864b409e6b3115757a3d4a09cca46cfb6cc65191b5de226b424b/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a9f54054101545a9a9cccefddf54316aa6e4491611fcbef9e91b3b6bebec04f6", size = 23668, upload-time = "2026-10-02T23:06:05.9Z" }, + { url = "https://files.pythonhosted.org/packages/09/15/98075cceac3b5ba0dbb8e4762a847be967d2befc349a2cf2d0ac77f62c9d/markupsafe-3.0.4-cp314-cp314t-win32.whl", hash = "sha256:12a606a492de952afcb43b59a14aaaaad120e708d3663dd0fdf2d738d427a691", size = 14301, upload-time = "2026-10-02T23:06:07.109Z" }, + { url = "https://files.pythonhosted.org/packages/0b/a3/768b560fcc4156685cb563d922b217810cfa7bc135773367f62f1f9d2078/markupsafe-3.0.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a18f38cafc329bac5e3c2b96c765b4c96d3d103421ed22ab7988c1e3fce27464", size = 14526, upload-time = "2026-10-02T23:06:08.276Z" }, + { url = "https://files.pythonhosted.org/packages/93/63/da554b4c97a6b0ea3229ca7fe8cbfb620be81613d517f482e85958550537/markupsafe-3.0.4-cp314-cp314t-win_arm64.whl", hash = "sha256:eba154571c16e032112afac0dc2dfe9e63c2ceb7aedd07bb7eecf2ce26d4dd4c", size = 14421, upload-time = "2026-10-02T23:06:09.402Z" }, + { url = "https://files.pythonhosted.org/packages/a9/30/54d11c8ca027114898cab97421fb39e4ffd9ddf47cdbc44df2ec76722da9/markupsafe-3.0.4-cp315-cp315-android_24_arm64_v8a.whl", hash = "sha256:737c9c3981998eba27f11786f84fddcbabc74068b72a4a1f454ea02094b57b65", size = 12791, upload-time = "2026-10-02T23:06:10.485Z" }, + { url = "https://files.pythonhosted.org/packages/10/6d/97c913e253a14bd3cd0e15a5c56d13203b823fa7ee32498342896a072dc4/markupsafe-3.0.4-cp315-cp315-android_24_x86_64.whl", hash = "sha256:489505b03f692c3f376394e49194fa7a7f9e8558d6e293a7056a0032b0c38163", size = 12527, upload-time = "2026-10-02T23:06:11.834Z" }, + { url = "https://files.pythonhosted.org/packages/26/f9/b86d032042a4d597d9e1997f0e5f63a3eedaf11258e0a05760b0a0a826ea/markupsafe-3.0.4-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:077293e425f28ec737dbcad442a71752e28f8ae27cde3d68acd1fb212091cd92", size = 11602, upload-time = "2026-10-02T23:06:13.122Z" }, + { url = "https://files.pythonhosted.org/packages/f2/dc/73c14c1eedf0ac5fa3292ba43435e6c49d2c2050f33cebde541f8f4807f1/markupsafe-3.0.4-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9348cbb300d224fe3b89793262cb093504d4ae927004468463f745188a193e4a", size = 11817, upload-time = "2026-10-02T23:06:14.227Z" }, + { url = "https://files.pythonhosted.org/packages/8f/69/2c2fcaa5fcee22d72c7819c0d536fd181c74a688e6143845419579cd2863/markupsafe-3.0.4-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:b807e598953730f82e4eae3bd30f6a122cf6b31c398c6b504c0e04c13c170429", size = 11446, upload-time = "2026-10-02T23:06:15.574Z" }, + { url = "https://files.pythonhosted.org/packages/88/54/9e5ec76c62e6e2834d5a93623018c943e8b3bb41d663e3fd4c03303b9b85/markupsafe-3.0.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:799c39bdf5e2f1292fedd3009f7b3c9e760f10b2420cb9638d56920840ff6db8", size = 11719, upload-time = "2026-10-02T23:06:16.701Z" }, + { url = "https://files.pythonhosted.org/packages/96/24/3ec292b44064c16229e064d770b2625bd8ea941aa61f44905a9fa44942c0/markupsafe-3.0.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:ae9dcb8fbe244cb82f8a6458b455b927a03685e383d9bacf1ea5ce180b96dc97", size = 12075, upload-time = "2026-10-02T23:06:17.855Z" }, + { url = "https://files.pythonhosted.org/packages/aa/85/b64fdb1f304848518742136983c24e96d967bfb59a0ea160e92736901ab0/markupsafe-3.0.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bced6e2a6dba6a28f7dd3c6ce14df1b2dd495923f16ea484cad03decd463b2b", size = 24656, upload-time = "2026-10-02T23:06:18.963Z" }, + { url = "https://files.pythonhosted.org/packages/9c/18/23997d4c65b355da6390d61cd56e0ab3befd6ba8dda25cb40c602bd0fa6b/markupsafe-3.0.4-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3882fb412298575bae3b9c46868251f15cc69307359f87bb1b382e53d6e5a2c9", size = 27737, upload-time = "2026-10-02T23:06:20.117Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/35998dead3c6af88c38265a56e58100211f036234ab88eb2283fd4cbce44/markupsafe-3.0.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04e7902ba80ee4bac1d50a549606527a1dcf0476cd81403db41099d3b60ec653", size = 25003, upload-time = "2026-10-02T23:06:21.284Z" }, + { url = "https://files.pythonhosted.org/packages/82/96/ef49135ce260db4ca4a12b119ed468449cd248db6b1468e2112b546d7a2e/markupsafe-3.0.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:925f929d6b59a8b3f8b8c6ac363cd0af7eecc81efb3071770b3c6717c450a369", size = 23111, upload-time = "2026-10-02T23:06:22.524Z" }, + { url = "https://files.pythonhosted.org/packages/50/7d/83126e338bd88c17a220668235368ad719fd4638e426739858cbb8508f77/markupsafe-3.0.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f68edfc67aabac33708941f26f22a7b8e9f81429bc0cf249fcf7d66b23af8d19", size = 22403, upload-time = "2026-10-02T23:06:23.785Z" }, + { url = "https://files.pythonhosted.org/packages/83/dd/daf7e420de23c8206c365204e7b85e1251d8e19d34196a56336f316e5ed2/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:e5c802729725bd07e2bc3ab7b76dc7e0bbfc53129d8f1eb1c002c24cf774717e", size = 24083, upload-time = "2026-10-02T23:06:25.037Z" }, + { url = "https://files.pythonhosted.org/packages/19/3c/11eecdc06bc44ad5570350085b572ebf049e8f9a38d1ece6d76640b739cd/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:55ffd6ce583d97dc71dc92e930324c8c0d25aea7e3ade6ae54ef77cedb096811", size = 22931, upload-time = "2026-10-02T23:06:26.328Z" }, + { url = "https://files.pythonhosted.org/packages/0d/9e/ac0fd77f2a726e56ecc3ca0235d095feace1358d1b822406c2a2ef26a4dc/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:2cb3dd71fc6be918ad4264346a8ed69485f9b7ed7bf35495d8e22807cd6b8bea", size = 24299, upload-time = "2026-10-02T23:06:27.742Z" }, + { url = "https://files.pythonhosted.org/packages/d7/09/c6bd842ad58ff5b3bc76eeed7e9a42a6f11adc5d090ec697b72c9672731e/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:94f5407f7bc64fa6463906b896f9904beeeb7dd8dc116ee8e9056c8714ff9916", size = 21948, upload-time = "2026-10-02T23:06:29.274Z" }, + { url = "https://files.pythonhosted.org/packages/a3/46/82f586711fed61e86faa1ee1bc317d68cd45a10c8bdbe3f7d1fdf9026ad8/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2dad610540cb2e6272855c178f08ae9a1c7ac258a7fb71660553a5f104b42741", size = 23150, upload-time = "2026-10-02T23:06:30.583Z" }, + { url = "https://files.pythonhosted.org/packages/19/2d/2dfdce99318abbfa26925195fbc17db188c46a1ec6457be121b6f9cfeb42/markupsafe-3.0.4-cp315-cp315-win32.whl", hash = "sha256:03470d1a8268e692ecf79ecd565593e59d44219377a7ead61f1f1b94c1f7ff6b", size = 14269, upload-time = "2026-10-02T23:06:31.949Z" }, + { url = "https://files.pythonhosted.org/packages/5b/ec/6000fd82e8791e58fcd0456ec20f098957e2b03d5ed02eb73241a577c0ba/markupsafe-3.0.4-cp315-cp315-win_amd64.whl", hash = "sha256:d882a373d8093c2941e01291b7ced96e9cbe4781da9a7751ca7e6c70385e5214", size = 14496, upload-time = "2026-10-02T23:06:33.258Z" }, + { url = "https://files.pythonhosted.org/packages/bc/66/e73bd5016421d5d6e2fb6de7dd609f9de020942ac8c626526bd8c6eeaf82/markupsafe-3.0.4-cp315-cp315-win_arm64.whl", hash = "sha256:353bd63081912ab8cfa6a0c7d185934cdf8426f04c618bba6bc4b394f2069b67", size = 14361, upload-time = "2026-10-02T23:06:34.539Z" }, + { url = "https://files.pythonhosted.org/packages/90/df/cb8c3dc98d313a951df2f8968f44e4cb5643df6d3cab749a530ce2f7d972/markupsafe-3.0.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c61750fadcd119d0825bcb7d7d675dd264dcc89cc05292aab5be68ebdbb374ad", size = 11760, upload-time = "2026-10-02T23:06:35.807Z" }, + { url = "https://files.pythonhosted.org/packages/d6/bb/4af9b3ca0753d654ac75f9531d5bd741bb77ca6e696f36807c475ffc099a/markupsafe-3.0.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:1c0df495a977d10460a94941799c72d5b5ab03d3858d949b55b5a66c8f371c99", size = 12108, upload-time = "2026-10-02T23:06:37.089Z" }, + { url = "https://files.pythonhosted.org/packages/3f/d4/b56429313aee5fd59b079c3df5615299959e25e7113eb6d8caadbdd7d38a/markupsafe-3.0.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02fa4acbc6a3fc5c693c34d4dd8c1130b7fe99cc915181b0ddd6f72aeb296002", size = 25762, upload-time = "2026-10-02T23:06:38.419Z" }, + { url = "https://files.pythonhosted.org/packages/65/f5/34c181e891aa4f7d59c918584672e0c5eb7fffe76c1387d1246008bf4081/markupsafe-3.0.4-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:05295589e619b9bed252a86b532b8e27350abc372d18ba89b59375325e91ec1e", size = 28400, upload-time = "2026-10-02T23:06:39.819Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b5/ad14694fd0ac9a5ce30bc6498f2999378f418583dd1679cca5a1b512957e/markupsafe-3.0.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:be6cb0c799abb0e2ba3e618e6d28ddddf7e485f6c2ce938dfa237daf3905072c", size = 25946, upload-time = "2026-10-02T23:06:41.381Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a8/26b606445387d0ceb1eb1f21840094b84e4e3c3c3983d80d10b89823b490/markupsafe-3.0.4-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:26e9867520db70d37f7fb421a7f0d8adb40171011fb84ce869afa1a83370dfa8", size = 23634, upload-time = "2026-10-02T23:06:42.748Z" }, + { url = "https://files.pythonhosted.org/packages/39/a2/b8814de672f1f0094d498bf646f2fec9d6356b503d28ef500b71c5095377/markupsafe-3.0.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f03460ff076f70ab595bb45a0205ccea1971443575b6920c52e755dec2b3fbfe", size = 23488, upload-time = "2026-10-02T23:06:44.176Z" }, + { url = "https://files.pythonhosted.org/packages/db/c7/287223376fb73335a3cc5d6eb22c6ab01358cf33945a9c39c06b9dac3f4b/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:436e3ffc6310d3c41878c601db29098102fe5d8a467c49da4a4125254e0980f2", size = 25037, upload-time = "2026-10-02T23:06:45.646Z" }, + { url = "https://files.pythonhosted.org/packages/f9/29/4df8355e313426d19e62ba33e0253c009ca12a0894ee77d67fa67255361c/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:4e2c4809c14559aa7ef426f27fb35afbb38104c349a903bf8f3600456764bb38", size = 23543, upload-time = "2026-10-02T23:06:47.264Z" }, + { url = "https://files.pythonhosted.org/packages/71/e5/8377731e8495668dcc768f645e717df18318c841edaf023a99395f6da9b4/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:da2af0d7aebfc2074080d72efa6ab8317c62481ef1f896f65d9999c1c01f4494", size = 25282, upload-time = "2026-10-02T23:06:48.795Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5f/373456e37ceb1478d657d6fe769cbe0a39f0a8dfc1548eeb19c471eefdd9/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:aa2c838cc024642cc04c6854232f32b43e5e22833dd11119c1766c7873b8370d", size = 22993, upload-time = "2026-10-02T23:06:50.31Z" }, + { url = "https://files.pythonhosted.org/packages/d7/93/2cbd5628435afb6f541bbaced4bce0c2edac4b09a142e6e928b8b0da9858/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b91cc9d336957239ff200f30097e6fea2dc6d6fb3c81e853eaa09eac904fd894", size = 23712, upload-time = "2026-10-02T23:06:51.759Z" }, + { url = "https://files.pythonhosted.org/packages/81/99/157e10966b033b363aeda5263e82596ee232a0b1d082fdbf90aa417ff083/markupsafe-3.0.4-cp315-cp315t-win32.whl", hash = "sha256:e49fb0d1ce92cfa0cb198cc5b1b11cdf9d0638658e2a2db2687e39db7c87fc78", size = 14300, upload-time = "2026-10-02T23:06:53.241Z" }, + { url = "https://files.pythonhosted.org/packages/33/05/55884815414c9706a23deca150b72c25a62109e65b0b6ce232077802c719/markupsafe-3.0.4-cp315-cp315t-win_amd64.whl", hash = "sha256:4f6e0852a0283b1b1fd776eeb7b766a5f440b3e2bd31ab51af3b400585f3965c", size = 14522, upload-time = "2026-10-02T23:06:54.729Z" }, + { url = "https://files.pythonhosted.org/packages/92/f9/ecbde7149e95b8a0f18e16d5d747f7dc06049d5da2e4f77f6f5e4a1f46a8/markupsafe-3.0.4-cp315-cp315t-win_arm64.whl", hash = "sha256:39dbacefc411633db5b4378b066a9aca70a3d7e2922c9e578d825f844026eeba", size = 14417, upload-time = "2026-10-02T23:06:56.246Z" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.45.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/dc/e12c1fe1ed8a7b7149777127b1a0e12ce5bd5a81d97408bedc2128c260f5/opentelemetry_api-1.45.0.tar.gz", hash = "sha256:711ede81773c8025c2c03dac0450bc89f3d30aea6eabcc815c570d4e35a963f7", size = 72115, upload-time = "2026-09-25T12:32:28.399Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/b9/040d1a1c7836922828e6480cd2366bb8fe0ebf75b413d2bb51a9b0e7f78f/opentelemetry_api-1.45.0-py3-none-any.whl", hash = "sha256:80e068aba7cd56c8b58512d6a36f8d25cb1dfaa0c0a4cc1c938ccf9f362d9cb3", size = 60020, upload-time = "2026-09-25T12:32:03.192Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "psycopg" +version = "3.3.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/26/3ea4ca5eaea1c0debcdf7ee7c1613fbe721dc27a03c461c0817ffd8a0601/psycopg-3.3.6.tar.gz", hash = "sha256:c081f2250df751a943036e42db6df4571c66cd0aabe8291a7a506512b12007d2", size = 168171, upload-time = "2026-09-18T13:22:55.152Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/de/748bd7609c71cae5d737f0ba9192f19329f70180ecda8fff3cac02c5abe3/psycopg-3.3.6-py3-none-any.whl", hash = "sha256:a1db9f7148b06a28606767efaca51fa6f9398c5c0a3810519be69d7000bdb631", size = 215490, upload-time = "2026-09-18T13:15:29.374Z" }, +] + +[package.optional-dependencies] +binary = [ + { name = "psycopg-binary", marker = "implementation_name != 'pypy'" }, +] + +[[package]] +name = "psycopg-binary" +version = "3.3.6" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/c3/c072584b69ad44a747b448cfc9766fecb8aae56e372a017e2ef668790057/psycopg_binary-3.3.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ad8f35e67cc16d1fad1fa8c88972dc9b3a3141ea67897399904edab96a301b6", size = 4712284, upload-time = "2026-09-18T13:19:13.451Z" }, + { url = "https://files.pythonhosted.org/packages/0a/b9/4283b785339e8e2318d03048994b093d650ea6289fabaa806b765dc0d449/psycopg_binary-3.3.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:373704aea331d3f3e3402c125a1543f5875e2986ebb54f97d1647942161f803f", size = 4772031, upload-time = "2026-09-18T13:19:18.524Z" }, + { url = "https://files.pythonhosted.org/packages/6f/72/7a1321d359246769fff1affffbd0132785a28f7f63c18524c15a502398f4/psycopg_binary-3.3.6-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:b82491019b884d62318b5f30706c3d7e6d4e5a6cb7eabcb3edc0c1b0fdaceae9", size = 5556392, upload-time = "2026-09-18T13:19:24.418Z" }, + { url = "https://files.pythonhosted.org/packages/de/b0/c6f8a0585a5dacbea74e130bcfc66629390e8f5bbc79d2a8e806e8952150/psycopg_binary-3.3.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cec5ea900390897d0b46130f60bc2883bf19c314f9044235217c8be88b0ef269", size = 5237855, upload-time = "2026-09-18T13:19:31.257Z" }, + { url = "https://files.pythonhosted.org/packages/e2/fc/c3a7a8bbef7e945ec584ac61d460a612363ea398511cd0e220242b1d69f1/psycopg_binary-3.3.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98c02090d88f2ebc0ec1e8da538f77d225ce0fffecf372aa39262e62a1b054ef", size = 6833856, upload-time = "2026-09-18T13:19:43.622Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f2/8e80b921db728ebb68fc105bd7c4277f908210ad755bd6481d5ea7add740/psycopg_binary-3.3.6-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ee2c4728c691245e24501fcd7a97b5b381236b9985bc445bba88cdce7d1b5784", size = 5070730, upload-time = "2026-09-18T13:19:49.968Z" }, + { url = "https://files.pythonhosted.org/packages/54/6a/5b313e0c5348244f0e973aff3258bf86766656256d5ece8d541a53e35b4a/psycopg_binary-3.3.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f19cc87343eaa55255e76b31259a570072ac95d6ae82c92dd34b97691f5e49dc", size = 4598089, upload-time = "2026-09-18T13:19:56.426Z" }, + { url = "https://files.pythonhosted.org/packages/32/e9/db7f76ec24bf6699e92bf604e5c4bae10664a681a8999ef42aa0faf0f2c6/psycopg_binary-3.3.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdccb3a0e184b03e9baa673b15a809cf36c339c85dbda0ebc25a698846dfbee8", size = 4278481, upload-time = "2026-09-18T13:20:04.681Z" }, + { url = "https://files.pythonhosted.org/packages/61/83/72c67013656f4d6b547caabffb193e91d57e63f90eefdcc6d045c400e97d/psycopg_binary-3.3.6-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9892188bb15e5803beb51afe8a25add6b56be391a53058e8bca03b74e1e6bf22", size = 4009229, upload-time = "2026-09-18T13:20:11.905Z" }, + { url = "https://files.pythonhosted.org/packages/82/35/5e4500df2c999eb0faed8b184e6958b834172128274f06167a5deef4c19c/psycopg_binary-3.3.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3af90f92769d8cc10f94515ee7a0aef36ea85ca733a0ce22858f6e0953f41138", size = 4321467, upload-time = "2026-09-18T13:20:17.949Z" }, + { url = "https://files.pythonhosted.org/packages/55/7f/e350e1cf498ba2565c3f87b12f429d2012eb86b76c2b3845a19ee5fbb4d6/psycopg_binary-3.3.6-cp313-cp313-win_amd64.whl", hash = "sha256:0ebfad5d131de9f892ae9e70cc7616207768b6714b66a52d4612b8ceaf78b372", size = 3658179, upload-time = "2026-09-18T13:20:22.691Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b9/60711317c284a442511644ea7185b56ebe627606d6741e732cd16108c47b/psycopg_binary-3.3.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:b3f75dee0f9afafabe4edc52c4842f1e1878ed2069bd05b22d6fe961e97e4dba", size = 4720512, upload-time = "2026-09-18T13:20:29.278Z" }, + { url = "https://files.pythonhosted.org/packages/63/da/28befc84454cbc6374550de7746f591f8fe1b6165c1fce249652cc8291c4/psycopg_binary-3.3.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5927b7ba63153cd8e9862987290a2b783a5c590daf2a4ef981700cc3569166d4", size = 4782318, upload-time = "2026-09-18T13:20:35.401Z" }, + { url = "https://files.pythonhosted.org/packages/a4/8a/0d21c2c833cdc0d4244c77e858e0ed37fa2abec2623be4fd686f617109ce/psycopg_binary-3.3.6-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:0bf08b749cc144f33b44a91b78e3f71c60eb07963746a0df5a100b36ce3d7475", size = 5567460, upload-time = "2026-09-18T13:20:41.902Z" }, + { url = "https://files.pythonhosted.org/packages/49/6d/7692d0d4e656b6cc9868d8acc2e3b42f17a0db4a625400a6d093cb0533a1/psycopg_binary-3.3.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:31cd942c23f613276b81a6e6598cefa12960058b0f46e1e874b540c793f6aca5", size = 5246902, upload-time = "2026-09-18T13:20:47.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/c1/b8a1f18fb1b7558a17f57f7cb3fc8bc93189feea2958925950b3acb15743/psycopg_binary-3.3.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4690cf67738f0e0e49a32aeec99bf0e4595cc2b4f1af984a4345394b1dcff91a", size = 6847192, upload-time = "2026-09-18T13:20:56.874Z" }, + { url = "https://files.pythonhosted.org/packages/a5/76/404f33519167c65cca88ec4998776f1dbebccc301ee977f0e62c47fb0826/psycopg_binary-3.3.6-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ad1c785e784cfd87e8436c6b7702f2d321fc39601bbaf29bc63a41a867091638", size = 5079573, upload-time = "2026-09-18T13:21:04.155Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d9/79e8fbc8f37262a415f3550f0bcc5f98037442bf3d12ef6cbae2056655ae/psycopg_binary-3.3.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:79a2a1c3449f6c3409427078ed1cec10de79f3023cb5f2504f0597d350ad46c7", size = 4613633, upload-time = "2026-09-18T13:21:10.664Z" }, + { url = "https://files.pythonhosted.org/packages/d4/47/96225db74be7d2ce04b3a58678b53cda610225055edf5faa775c9f501d8b/psycopg_binary-3.3.6-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:86147cb5d140341c3363fb5bacce31f8d5543902a46699d3c536b101bbceaf9e", size = 4293375, upload-time = "2026-09-18T13:21:16.027Z" }, + { url = "https://files.pythonhosted.org/packages/2a/d2/18e9c779a5efd565250329adaf529ecc2b8b2ed5be5cb0f6ccee208cbfd9/psycopg_binary-3.3.6-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7308c93cf0b19bbaf8e6ff0a6ad50d3c442385739245fe15a8d593bf841734a6", size = 4019883, upload-time = "2026-09-18T13:21:21.587Z" }, + { url = "https://files.pythonhosted.org/packages/ef/28/0cc654afc6c2cda982767f5679d3646b30b1ec86545bdaa9402202d6776c/psycopg_binary-3.3.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:05a83ac9fd52b9bca7cb5ab04b3691163170bd16f53defa27216ea3aa07ee781", size = 4332607, upload-time = "2026-09-18T13:21:27.63Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3e/0a753a74fbd7aef120f286c016e09d3cc3f1daf7688f4a145d27281260b2/psycopg_binary-3.3.6-cp314-cp314-win_amd64.whl", hash = "sha256:1fbd30e537dab22cafdf080608f10148fe2a5f3a61294ddb5113caac8a623840", size = 3755671, upload-time = "2026-09-18T13:21:33.855Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b1/a372b9c02aea50148e71c9853e19efca8fa5ae2010a8e27243b9b8f790c0/psycopg_binary-3.3.6-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:bf8c8481d026b85dd70c5fa7dde85b2333aed0b32a2602bcd38a900cbd78a49c", size = 4719571, upload-time = "2026-09-18T13:21:41.437Z" }, + { url = "https://files.pythonhosted.org/packages/65/7c/811e3828c6b82e2f10c6c9cdd963cfc66f3e024026e5a69ac18530bad984/psycopg_binary-3.3.6-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:b599defe9190b17e9907c8b4d114c181e702c87efcd1b8a0ad40971cdcc4634a", size = 4781230, upload-time = "2026-09-18T13:21:49.516Z" }, + { url = "https://files.pythonhosted.org/packages/3e/15/9a784eed813ea9e97c294af3ead63d02b7b203502c66380336c50065e441/psycopg_binary-3.3.6-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:b8ece331509f7a975b90501f41e83ad905e4141753fedf3f2711b2bc70a8efbc", size = 5566111, upload-time = "2026-09-18T13:21:58.089Z" }, + { url = "https://files.pythonhosted.org/packages/68/16/47194e002007c27337b11e49bf459c4b19727463f9aff2e1a90917bcc806/psycopg_binary-3.3.6-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c61617eaae0112ca154da87ffb99b73af2c74067acac28dfb9a4455b019dff2e", size = 5249963, upload-time = "2026-09-18T13:22:06.695Z" }, + { url = "https://files.pythonhosted.org/packages/53/84/5dcf9f310b11f0675cd860c6b2c70f58ce61798a3ee3f6f962b53fa358ca/psycopg_binary-3.3.6-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c6d19cb4999d03231e8730a5f66c8f5068bc3b532677eb39dab0f600bff3e312", size = 6847925, upload-time = "2026-09-18T13:22:13.088Z" }, + { url = "https://files.pythonhosted.org/packages/f3/06/1957a06dc22963c418c27b284929579de84f29c37ad1abe6dc6ee9e8cf25/psycopg_binary-3.3.6-cp315-cp315-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8cbb54454dbf1bbf2ff08dd7693e8d94ac94b1a20f70f4b3b813d52ecb5cbc1", size = 5087720, upload-time = "2026-09-18T13:22:17.959Z" }, + { url = "https://files.pythonhosted.org/packages/21/43/ac07d042bae99b57bf123bb473632f29af544008094da0ffd285ab8011e2/psycopg_binary-3.3.6-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:dc75da5a20951049f7b773145f998f69d181adad9c58a0ff36e0cf1d73c10e10", size = 4613412, upload-time = "2026-09-18T13:22:26.719Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b1/019156fbeafcefb4cccc9d109de4699493bceb8313c7545c8349e089dfbc/psycopg_binary-3.3.6-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:955e3dd94da361e052d2e49acf591017158dc8f8ed2c8a42c2e3943403c39dc2", size = 4292618, upload-time = "2026-09-18T13:22:33.042Z" }, + { url = "https://files.pythonhosted.org/packages/5d/0f/62113dc6b1df65983a1f2fc816c04b1edfa22f2ae9d4abee74ed267f4a96/psycopg_binary-3.3.6-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:c7753871eb57e6a5f4646f6168590c6653073dea5e9e720b201c8875332df4c8", size = 4027121, upload-time = "2026-09-18T13:22:38.334Z" }, + { url = "https://files.pythonhosted.org/packages/5d/d5/cf0cbd1ea5a7d8167fe2c6953efde19101f7b193bd61a23e6d622ad6854c/psycopg_binary-3.3.6-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:303732e798fe6729f8e12021b9c96107df8e95ecec4dd487c67b98ec2a59435e", size = 4336388, upload-time = "2026-09-18T13:22:45.576Z" }, + { url = "https://files.pythonhosted.org/packages/98/33/e2a5b36edf8aa422f6fa4b894756eb33dc93b36df5f65121280bb8b929c4/psycopg_binary-3.3.6-cp315-cp315-win_amd64.whl", hash = "sha256:2f122603f36050937982abf9668d8bc4769a79f7c93a65013b1c49f1cab7b56b", size = 3756154, upload-time = "2026-09-18T13:22:51.283Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/37/5abe39a8372a61d3dc3c1338fc504281c01b32fdb3169cd7187153b56d3e/pydantic_core-2.46.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0", size = 2075885, upload-time = "2026-08-28T09:58:47.856Z" }, + { url = "https://files.pythonhosted.org/packages/21/43/6323b1f8b217780454c61304bcd2b38ae4762f50754414124603ccc90bb2/pydantic_core-2.46.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff", size = 1922768, upload-time = "2026-08-28T09:58:49.58Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a3/c05ca796e1197618a774b01e596aeedfefc2f7d8c01ae3054e910b120e8a/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931", size = 1951241, upload-time = "2026-08-28T09:58:51.511Z" }, + { url = "https://files.pythonhosted.org/packages/68/32/33bc39ac705c52cffc908e8389f9754fdb208aea5c69cceddf4eb3ce99af/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f", size = 2031975, upload-time = "2026-08-28T09:58:53.166Z" }, + { url = "https://files.pythonhosted.org/packages/b0/70/2333e885c0f6a67bc105c5916965dac9b57f2718ee20d81d1a06a4ebdc13/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038", size = 2208542, upload-time = "2026-08-28T09:58:55.017Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ea/296debfb4264207bbda5936133892e027c0a58875ad53ebd512fba8ec3a2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f", size = 2264692, upload-time = "2026-08-28T09:58:56.767Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/9e4de77a6271e07a76d2d58b11c091a979c191ed2939bf80067568b369d2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1", size = 2066633, upload-time = "2026-08-28T09:58:58.531Z" }, + { url = "https://files.pythonhosted.org/packages/8d/db/f9e9d0c97445987b2084823d5c240de88087338f04fc2cfaa2df186b8049/pydantic_core-2.46.5-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761", size = 2105235, upload-time = "2026-08-28T09:59:00.421Z" }, + { url = "https://files.pythonhosted.org/packages/07/c5/79169b047b3b2c3e99e04bc76372af9637e0bf6db638274fa927df96369e/pydantic_core-2.46.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5", size = 2157367, upload-time = "2026-08-28T09:59:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/26/b5/ba6057afb7c291bd449f51b867f95aef2072941c4ce4e5c31d6ffd132d3b/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e", size = 2158420, upload-time = "2026-08-28T09:59:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/6e/28/2057abecaafdc22912afa819603a51f0a62d40643b7c4871c51721fea9be/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed", size = 2309588, upload-time = "2026-08-28T09:59:06.048Z" }, + { url = "https://files.pythonhosted.org/packages/71/9d/881156dc404e27479c4246128d73538464cab4a239bec61995e227644c30/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519", size = 2341866, upload-time = "2026-08-28T09:59:08.539Z" }, + { url = "https://files.pythonhosted.org/packages/5a/38/d66f443a259f84d13babdceae568e572b0ed26da17ca5d0a649ebb110a67/pydantic_core-2.46.5-cp313-cp313-win32.whl", hash = "sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea", size = 1938580, upload-time = "2026-08-28T09:59:10.402Z" }, + { url = "https://files.pythonhosted.org/packages/2c/1e/1d5371213f4cc9a7ed70c0bfcc7911de22311ee99a662a56077d7292d2ac/pydantic_core-2.46.5-cp313-cp313-win_amd64.whl", hash = "sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5", size = 2041980, upload-time = "2026-08-28T09:59:12.396Z" }, + { url = "https://files.pythonhosted.org/packages/5a/48/4222d90b1c67568bace4dec6dca6271449c66de3595d72b6d098f5fde597/pydantic_core-2.46.5-cp313-cp313-win_arm64.whl", hash = "sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575", size = 1997213, upload-time = "2026-08-28T09:59:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8a/14596f2a8367da50cf7cbac48169ee5d9c8e11d486a3b527082384630c72/pydantic_core-2.46.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355", size = 2074081, upload-time = "2026-08-28T09:59:16.141Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d5/d8a4eb6d6c7f66b91dd37c576d76e9e60fba900caf5372c17bcf949febc2/pydantic_core-2.46.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e", size = 1920497, upload-time = "2026-08-28T09:59:18.065Z" }, + { url = "https://files.pythonhosted.org/packages/8e/26/092079428f86e927e030b2c0ced87df69dbb1c875cdeaa67bf42ea2be746/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3", size = 1952130, upload-time = "2026-08-28T09:59:20.476Z" }, + { url = "https://files.pythonhosted.org/packages/08/c3/8ec0e290a9ebaebd64047bf5fda94be835c6b1551b02437e4b76778fbcd7/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c", size = 2026371, upload-time = "2026-08-28T09:59:22.227Z" }, + { url = "https://files.pythonhosted.org/packages/01/72/4fd20ad520fb8da0157f95b27a7eb05a72790ef08138e7701ac972c342ea/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21", size = 2202822, upload-time = "2026-08-28T09:59:24.277Z" }, + { url = "https://files.pythonhosted.org/packages/31/b0/d16e0771206b29314f0d52198b720be21e8a99ab2bf11e3bc0d7c9cebdff/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f", size = 2262756, upload-time = "2026-08-28T09:59:26.608Z" }, + { url = "https://files.pythonhosted.org/packages/2c/9b/59634b7ac631c63b2a37760eb6943af3e29573d6b59a4abc5e7f019d4cee/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f", size = 2068352, upload-time = "2026-08-28T09:59:29.044Z" }, + { url = "https://files.pythonhosted.org/packages/08/7c/570abb1ad2155348dc754ea91be22e5aaa18eb6d69a6068f7c6f2679a6ed/pydantic_core-2.46.5-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a", size = 2104777, upload-time = "2026-08-28T09:59:30.95Z" }, + { url = "https://files.pythonhosted.org/packages/8e/25/5bf74adc65a1ac5b7be3f6cb0bcb5433615c1598a801c19d830d84c98ded/pydantic_core-2.46.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821", size = 2156312, upload-time = "2026-08-28T09:59:32.604Z" }, + { url = "https://files.pythonhosted.org/packages/90/6a/2ef38830675e050121040618135564ed56b860b45433b02d9b4ebece46f3/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2", size = 2150067, upload-time = "2026-08-28T09:59:34.453Z" }, + { url = "https://files.pythonhosted.org/packages/90/ef/a7dbb03a14a64c2a4621f989c615ed9a892535a6cad938fc27079f919d80/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47", size = 2304516, upload-time = "2026-08-28T09:59:36.194Z" }, + { url = "https://files.pythonhosted.org/packages/68/f8/6bb4c4b80e8a6fde1904c64a51c62a1d04fcdfa3ea521a66b2ddefa1d885/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a", size = 2335223, upload-time = "2026-08-28T09:59:37.931Z" }, + { url = "https://files.pythonhosted.org/packages/2a/80/f46b8c681195190b2c1f1c7c0a81abce60663e987613e09ef64d433dd96b/pydantic_core-2.46.5-cp314-cp314-win32.whl", hash = "sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074", size = 1934827, upload-time = "2026-08-28T09:59:39.836Z" }, + { url = "https://files.pythonhosted.org/packages/f7/3c/60674207246bc0a4009d2391b7c7251c7159f279c8d2ab8aae8ef46f3dee/pydantic_core-2.46.5-cp314-cp314-win_amd64.whl", hash = "sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0", size = 2042648, upload-time = "2026-08-28T09:59:41.792Z" }, + { url = "https://files.pythonhosted.org/packages/69/0c/117c562c7c1babdf44576b72a5e496906506c93690387ecfbca7c729ae2e/pydantic_core-2.46.5-cp314-cp314-win_arm64.whl", hash = "sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5", size = 1989652, upload-time = "2026-08-28T09:59:43.702Z" }, + { url = "https://files.pythonhosted.org/packages/e8/66/9336ae58f9eb68c41d121894e52c4c89eccb07eb8f602a04ee9c3f37736a/pydantic_core-2.46.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7", size = 2065829, upload-time = "2026-08-28T09:59:45.364Z" }, + { url = "https://files.pythonhosted.org/packages/c5/02/bc19b47a96c2d3109760711acf22369e56bd7e405ca52f7ade164d2ead57/pydantic_core-2.46.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3", size = 1905716, upload-time = "2026-08-28T09:59:47.18Z" }, + { url = "https://files.pythonhosted.org/packages/52/a4/70b47c0509923dd98ccfed04fb3e32ea3849c82a0ff2205bb41009b43c00/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f", size = 1934216, upload-time = "2026-08-28T09:59:49.241Z" }, + { url = "https://files.pythonhosted.org/packages/52/ab/aa03b65f7bb198585edf806b906c3223ecf1795543e39e23aec4cce27ad2/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7", size = 2010635, upload-time = "2026-08-28T09:59:51.692Z" }, + { url = "https://files.pythonhosted.org/packages/3c/8b/0da06343f30b84ec549aafd309c6456223d5dc8bd36af504c573faad561d/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c", size = 2209369, upload-time = "2026-08-28T09:59:53.582Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5b/844c4defaa34a3df66eb9257087d121d70c201298b96abdf9f492fc2f1bf/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111", size = 2253238, upload-time = "2026-08-28T09:59:55.484Z" }, + { url = "https://files.pythonhosted.org/packages/f4/64/a4e536cb16d7f61a7fd3120b46c577fc7fa7325992f69c4f52bc786d77d8/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829", size = 2065740, upload-time = "2026-08-28T09:59:58.038Z" }, + { url = "https://files.pythonhosted.org/packages/5f/75/aaa38c6bc2d085f6605b34eabdc6a8a4e0b2e61fc9c8e6e52b28e97b3125/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa", size = 2087425, upload-time = "2026-08-28T09:59:59.898Z" }, + { url = "https://files.pythonhosted.org/packages/55/ae/fcab4cfc39aba3689e1d20c8b5250ad280957022c09af2ed9cd585602a5e/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034", size = 2139306, upload-time = "2026-08-28T10:00:03.057Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f4/f1d03a4bc9d9acbc62f4d742b8a319af52f71885079868b2ff8e48a651ee/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184", size = 2144589, upload-time = "2026-08-28T10:00:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/83/f3/7a53bb1356de514a4cd295f25b6ac39237895620c0462d2592b76c16e114/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38", size = 2288882, upload-time = "2026-08-28T10:00:07.931Z" }, + { url = "https://files.pythonhosted.org/packages/cd/94/5a81583660c175c59d49ffb09f4b3a44debeaf86a19fca664ae1cdd9ee32/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9", size = 2335210, upload-time = "2026-08-28T10:00:10.177Z" }, + { url = "https://files.pythonhosted.org/packages/5a/9f/5d685c2693b972d1a59c998586e8823712b66603aeff47ee60a4bdaafd37/pydantic_core-2.46.5-cp314-cp314t-win32.whl", hash = "sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9", size = 1921180, upload-time = "2026-08-28T10:00:12.35Z" }, + { url = "https://files.pythonhosted.org/packages/70/12/5c94ee16d65a37a15f9e869f5e6256df111154491173801a4c5e800ab548/pydantic_core-2.46.5-cp314-cp314t-win_amd64.whl", hash = "sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290", size = 2020515, upload-time = "2026-08-28T10:00:14.774Z" }, + { url = "https://files.pythonhosted.org/packages/63/19/67830dda664e6bdf9285ee2e40f355d0d7d6b92aa0c42e8d217bb8d33d36/pydantic_core-2.46.5-cp314-cp314t-win_arm64.whl", hash = "sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f", size = 1989276, upload-time = "2026-08-28T10:00:16.984Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/ca/31c57507b13119d7d3cfa1576dad2911a4861e3be07b579395f4e9d393f9/pydantic_settings-2.15.0.tar.gz", hash = "sha256:694b793e84f766ba76a90ebdefc01d0a9a045dab0382bee70393da93712ad117", size = 261253, upload-time = "2026-08-07T09:24:57.419Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/a4/2bffa9f8e804325a09867f0e9d30795c80ea9f8d62560bd1b6ad6220eb2f/pydantic_settings-2.15.0-py3-none-any.whl", hash = "sha256:0ba092c291c94baceb5eff768aa0d56400a457585bc0175925a5a5510303da42", size = 69413, upload-time = "2026-08-07T09:24:55.839Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/74/26/2fbeedb218a787a5eea551c7532cac4e009f83d689dd2faa0d0353473f86/python_dotenv-1.2.4.tar.gz", hash = "sha256:f0d53e69935a851c0dcc78f3ab7aaccd8cabef0b92382b576b824212902873c0", size = 60824, upload-time = "2026-10-01T05:36:10Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/d1/38f3a3405989a89ac18390803e70c6ad7c7760da4f9b83cbeca0c44a0c72/python_dotenv-1.2.4-py3-none-any.whl", hash = "sha256:42269a8a5b3fd54ffa6f3d84b18abed50064717576b4ecf03dc4a55d8aa04fdc", size = 23266, upload-time = "2026-10-01T05:36:08.633Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "sqlalchemy" +version = "2.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/4b/81d972a46c9f1d978af1795e2abc988855c711453552cb45d75f6e4abbf5/sqlalchemy-2.1.3.tar.gz", hash = "sha256:ade5281df06038c6394f532590d592d3381ee5d11b9e0006a2570aef41145118", size = 10525778, upload-time = "2026-10-02T22:57:00.308Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/71/68c29931fb2c5178241f7d0c63dbdd800ad65685eac70d7f2117145b5e3c/sqlalchemy-2.1.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:260bdd8baa1dc631d76d14b26ad009ebbb665b4469666afef8dcaa1f964016cb", size = 2457479, upload-time = "2026-10-03T02:39:19.741Z" }, + { url = "https://files.pythonhosted.org/packages/94/21/5dbd91816b2cdb367bca3195f300ffeca916762e088d6924fc33a18f50ad/sqlalchemy-2.1.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5fc01a8047d6a4f4e997441994f07af79fdd470fc80669751e32f91153d6b491", size = 4582199, upload-time = "2026-10-02T23:37:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/32/d1/79962ce06d6cd34516def113f5170184fa9401ebddc59fd6cb18f6e919f3/sqlalchemy-2.1.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b1b3ff10eba0f9290142375e667351c99d24ee7fcff6e0f85bd8be9d39b50a17", size = 4632923, upload-time = "2026-10-02T23:35:55.318Z" }, + { url = "https://files.pythonhosted.org/packages/3c/94/03e98031dc9a1738c580c3bf01beeafe695f247eb647f4e8dda3fab2454f/sqlalchemy-2.1.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:efb1ebf695e80d9a69f5bbff9d812d2491fd168b27d6b42ad4c8a4d3d6faaef1", size = 4300224, upload-time = "2026-10-02T23:46:45.495Z" }, + { url = "https://files.pythonhosted.org/packages/3a/1f/388c87607eb6c7abee4866355eeebb6221b3bcb413a64ce4a5cfc3edd22f/sqlalchemy-2.1.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a95754928d47b37ea5c4b5180101db2afabef1da0dd5cd0c573b547d5d036ff0", size = 4507170, upload-time = "2026-10-02T23:38:29.697Z" }, + { url = "https://files.pythonhosted.org/packages/57/73/5515a3f555d31748564fbeb0b9e0b6ab7a647bf2ea0cdb6d233f20f710e3/sqlalchemy-2.1.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:7606b413730ac6002930faa96f52195ed6969040b78071a3336370661f5e9e09", size = 4301439, upload-time = "2026-10-02T23:46:47.845Z" }, + { url = "https://files.pythonhosted.org/packages/a3/a0/0eae9b96f6f8a09f3e5d388f6fc5d6fe8d5ff2aced2a5c75bea7333d445e/sqlalchemy-2.1.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:76c3d5c7ada0925ff12e8c2395c547f88fcfccc13368ff8e8dd55ab5e28dcdab", size = 4594441, upload-time = "2026-10-03T02:46:16.928Z" }, + { url = "https://files.pythonhosted.org/packages/f5/b1/b6d681015414ce9048c07ef3ac49b693c1b8373de4716ee4bdeff4ec19a0/sqlalchemy-2.1.3-cp313-cp313-win32.whl", hash = "sha256:cb6fc5c856e8f1c0228bf74b636c0a03a6c12dd502ced7aa82ced21dcdc7f547", size = 2371547, upload-time = "2026-10-03T02:49:23.549Z" }, + { url = "https://files.pythonhosted.org/packages/56/ad/35aa4d737016640c6da7573a4bcd9d91b59a87359bc6df075627963a4781/sqlalchemy-2.1.3-cp313-cp313-win_amd64.whl", hash = "sha256:82d4c63d338737d593f1d29c4dae0a124577a9a76186d90b5c0f9ec9cbeeab35", size = 2421558, upload-time = "2026-10-03T02:49:25.448Z" }, + { url = "https://files.pythonhosted.org/packages/fc/42/0dcc7cd92a3f4773376a5488b6ce0d6d75d2d029ad7e7f9e0ecc5fb48fcd/sqlalchemy-2.1.3-cp313-cp313-win_arm64.whl", hash = "sha256:0ab6f42761cc5c48d2f3e8a70810d10630d8222a51a7111cf9b3a48f22c3f148", size = 2382507, upload-time = "2026-10-03T02:42:42.812Z" }, + { url = "https://files.pythonhosted.org/packages/11/55/d626c933b7d034619c57120fafb47c0d44b2553ad2b8f74e904a40c01258/sqlalchemy-2.1.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:660c6e16d254568874d4b77fd593aa5426f55cf40d0acc8b5364a5e61cc1044d", size = 2460626, upload-time = "2026-10-03T02:39:21.751Z" }, + { url = "https://files.pythonhosted.org/packages/35/90/9b276c8eed59694d5c2291588fae9c51f25a5c63efb00ef9a7c6e754a2cd/sqlalchemy-2.1.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7277ba31ffb636666ed207d2aaf74de3cd2ee78436da7552bd4eeadf1f67740a", size = 4576390, upload-time = "2026-10-02T23:38:31.943Z" }, + { url = "https://files.pythonhosted.org/packages/60/21/818e89fe73ba4600abf5090e0d2356d92de9ca3a8aa9222d64801e510bd2/sqlalchemy-2.1.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b728c406b1b202e8998e8f9adacb56773bfeea67da74252ad3025bcb8f5863f1", size = 4606177, upload-time = "2026-10-03T02:46:19.291Z" }, + { url = "https://files.pythonhosted.org/packages/88/75/ab96bb0e27153d123340d6f1b14fac3713c03b7e3ca3f74f4c0715345775/sqlalchemy-2.1.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:85623c57c3696dcbc4fc909bbf335a2f0c27ae36f762d83810067fb49abc040e", size = 4300438, upload-time = "2026-10-02T23:46:50.03Z" }, + { url = "https://files.pythonhosted.org/packages/94/49/c7e0223fe5553ff1c31853c07f158f75245f8d435435755a789249f5121a/sqlalchemy-2.1.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ac0de6dc326cc99ac31206359e9fc2c13345f667edeb6ef556585fedac4ff698", size = 4506089, upload-time = "2026-10-02T23:38:34.209Z" }, + { url = "https://files.pythonhosted.org/packages/10/83/d6aad5e1a5b3a3e9257f932971b7f2105be6669205d4d9946f5f87d73216/sqlalchemy-2.1.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:65e002b794efa1bacbbde9fa973e5340d6c3c29b53e592b863a281802829319d", size = 4301730, upload-time = "2026-10-02T23:46:52.115Z" }, + { url = "https://files.pythonhosted.org/packages/04/54/310b9fd03ba7de6359185a0914bcd0cf45a76c6314e49d1ec814a50ae13e/sqlalchemy-2.1.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:65e4813c78eaa679c9369b289e67fdba924db6be14067700c5ccbda37953af9a", size = 4565315, upload-time = "2026-10-03T02:46:21.64Z" }, + { url = "https://files.pythonhosted.org/packages/d4/8b/f6f7d46bc4e7bdc16fa78ec9a390f01852f2b080b7c75e83db4947b707c6/sqlalchemy-2.1.3-cp314-cp314-win32.whl", hash = "sha256:726987e7f8500614016be056ba5c9715b760ab465295741e2087b29753d72632", size = 2378402, upload-time = "2026-10-03T02:49:27.524Z" }, + { url = "https://files.pythonhosted.org/packages/9a/5f/dabd1ea48dd6c45383526c7f2e453fea15dcb53235edbadab49857fd7bfd/sqlalchemy-2.1.3-cp314-cp314-win_amd64.whl", hash = "sha256:0560458a775917bcd7a47eccf4f9e0b135fd9b72d7b819cb1bd60581de946f9a", size = 2428828, upload-time = "2026-10-03T02:49:29.481Z" }, + { url = "https://files.pythonhosted.org/packages/23/aa/ecba6a6141d24dbd4b002b451e0c410b7c6cb82a8a873f915f89314af830/sqlalchemy-2.1.3-cp314-cp314-win_arm64.whl", hash = "sha256:63c9289ed81991ba4d500611ce201531711430f5d23d7ae9a61a8842639b85ea", size = 2393599, upload-time = "2026-10-03T02:42:44.416Z" }, + { url = "https://files.pythonhosted.org/packages/33/41/f2c5eb9371cc1352a16001642702dfc2d73537995eea5cdeec7738f90a9d/sqlalchemy-2.1.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:08b2f345db8b0742e7814c49f9a210d5f5c8fb9d80d12c8a63bba762fa277477", size = 2500084, upload-time = "2026-10-02T23:35:35.774Z" }, + { url = "https://files.pythonhosted.org/packages/73/f1/fabd748dd8a1700579906649ac05414d65eca5bca44a3ac3c22856ed8d25/sqlalchemy-2.1.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:493b632f426579c4b0e97a5610f76c8c3d401cede442d462bf700b537035c91e", size = 4904105, upload-time = "2026-10-03T02:49:06.101Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6b/a7a3d5c77c16386e16eb9dc8ba82e0cc6b03d6ee0ef6620921655df82413/sqlalchemy-2.1.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:55e06933580f4faa5a7b1d2c9ad04f365fe01c40465eb982e38fda8758c1e95c", size = 4814161, upload-time = "2026-10-02T23:34:39.253Z" }, + { url = "https://files.pythonhosted.org/packages/63/72/275dac552b869a21774c08a7084bad16bd99f28384b8c95ffcded1a18884/sqlalchemy-2.1.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fdcee9536cb3281b8810787ba651f4e5f22bd687b177de89fba2155b055b07b0", size = 4494471, upload-time = "2026-10-02T23:59:11.642Z" }, + { url = "https://files.pythonhosted.org/packages/e8/3e/41d8071c58a20e2ba9fabc422938a219461f0b32aa537ab69afe06daa0e5/sqlalchemy-2.1.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d2cb28779dbf54067655030d7ab3a6726c7957c56bde5ff6745ed86b5cebff41", size = 4762070, upload-time = "2026-10-03T02:49:08.052Z" }, + { url = "https://files.pythonhosted.org/packages/75/73/b747233b8ad935467eb1f2aa1c466af69831693cab3495c88842bf1bd3ac/sqlalchemy-2.1.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:01a90cac3f8d93fcbda0df119ef56c4d58aad5601f6b7ee5a38bfabbd5d119b8", size = 4494691, upload-time = "2026-10-02T23:59:13.733Z" }, + { url = "https://files.pythonhosted.org/packages/71/aa/ba11b725b9529879aff489e36cdaccc3fd69620fb30b89186792523bc262/sqlalchemy-2.1.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:edbc28049ab8da07387ea2b85ceee747b36b25444ffa4aaba9f6e1461d5b2634", size = 4752692, upload-time = "2026-10-03T02:54:01.04Z" }, + { url = "https://files.pythonhosted.org/packages/b6/b8/86ec625db10e285ed8459e39ab3e56b7630554bc733e64a190e248eeea25/sqlalchemy-2.1.3-cp314-cp314t-win32.whl", hash = "sha256:724f587ced076d89291032b35476d6bdb401cce1dd680c1e3418c01d9bd55ad4", size = 2436746, upload-time = "2026-10-03T02:56:47.297Z" }, + { url = "https://files.pythonhosted.org/packages/18/be/1309b0b3acfcd6e0060c815762012c66d23eece8c9eb8091da5c850a1289/sqlalchemy-2.1.3-cp314-cp314t-win_amd64.whl", hash = "sha256:935085a5b8849d324c22bc3cc09fd07b3669cc22ba5bdd3ec208829e535cdef9", size = 2504160, upload-time = "2026-10-03T02:56:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b2/ac9067f4d6c43887570df54e6c340ae9c591a8e75a5fd3029a9d7deba163/sqlalchemy-2.1.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2ebfff82cece947fea1ed6337d4f0613b9799f6db621c9729790fdd41edfb5ea", size = 2422266, upload-time = "2026-10-02T23:35:30.802Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e5/54a863bf8e12fa97cd06bb2e012aeca53b5535bc07aabe0192a4ed77ed44/sqlalchemy-2.1.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:47a052dcd935a62326216bc5f0ed10ad941c77c3acc9200385e0c03c4f31d5a9", size = 2460525, upload-time = "2026-10-03T02:38:02.872Z" }, + { url = "https://files.pythonhosted.org/packages/f9/63/f6f8a8d8e8f8be456bea70ad2b9af90a60a7f98d349b178b9eaaea86431b/sqlalchemy-2.1.3-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e5916b43a9a8f346026f663583f203d0c43febad504ff2fed504dec6e078f20", size = 4579659, upload-time = "2026-10-02T23:35:35.789Z" }, + { url = "https://files.pythonhosted.org/packages/fa/7f/9f1064ef0f8f15e3c1cb8fa7ad5e4fc7e1e311ad0e1e46bc2e77940585bb/sqlalchemy-2.1.3-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9af78a85af63114e2650f019ddf8fffb871e659e92bffb7d0bce5753f706caf", size = 4615129, upload-time = "2026-10-02T23:38:44.119Z" }, + { url = "https://files.pythonhosted.org/packages/c4/4a/f1a002235cc785d219738e096c7c65f68e9b7ed30c810997236567c50af9/sqlalchemy-2.1.3-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d218698c4f05728a941d6ad7c769b6491f03058e102cf811b7e4900f78fffa07", size = 4316528, upload-time = "2026-10-02T23:39:13.275Z" }, + { url = "https://files.pythonhosted.org/packages/a8/9c/6ee4e5ab55558408c16fe1713db415e45024f42cdb1c573ebceacc09d01c/sqlalchemy-2.1.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:80e6ccf111bc3f57530935f5541c6c1da50ac9713c418378840683532a45dd2d", size = 4509727, upload-time = "2026-10-03T02:41:25.314Z" }, + { url = "https://files.pythonhosted.org/packages/64/8c/a6bdf2874518c628fdedf24144194afa88cceb70339e5050459cdc2723cf/sqlalchemy-2.1.3-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:9a3ce1ff88dfb9f9f4305da07f4fab87720b609e264d134e47ae84a52df130bd", size = 4316490, upload-time = "2026-10-02T23:39:15.342Z" }, + { url = "https://files.pythonhosted.org/packages/10/3b/d859b638495a63ef7c155a8185369fa86ba2b860154d777e46eba922324f/sqlalchemy-2.1.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:77a365f7cb81bbd738c948b64e0b683f8f1b68efe51ca2815e8ad4baf6287ed9", size = 4576491, upload-time = "2026-10-02T23:38:45.97Z" }, + { url = "https://files.pythonhosted.org/packages/23/0b/c560177600613df0cfe27e27c346bac956b631cd5236178b81144e783e9e/sqlalchemy-2.1.3-cp315-cp315-win32.whl", hash = "sha256:83757320391b96cde715af0772675941d3e345da9ced3696c030f7f55ffa8eab", size = 2378116, upload-time = "2026-10-02T23:14:57.441Z" }, + { url = "https://files.pythonhosted.org/packages/d3/72/d39f60d0bc873653c0fec76aa088c969a2394ea7737f18d812fbe0e8c0c5/sqlalchemy-2.1.3-cp315-cp315-win_amd64.whl", hash = "sha256:f87533295b84c9e19ca2a72dc26a76e0192f0a2424f8fd575a82e75e7d58f9e1", size = 2428865, upload-time = "2026-10-02T23:14:58.959Z" }, + { url = "https://files.pythonhosted.org/packages/c5/03/953cf93136de44ee9d6cebee14666dcc2c4a4285fe159b2e459326631e49/sqlalchemy-2.1.3-cp315-cp315-win_arm64.whl", hash = "sha256:3e46a27b0c74af74aa6c08c983ca51db88d22dd86c316d72a634e989b7caef58", size = 2393289, upload-time = "2026-10-02T23:08:31.958Z" }, + { url = "https://files.pythonhosted.org/packages/a2/1e/5b5af9c0fe4e6f713d2ab684b88f10fd1cbe55bd34cca4d676fb930c9da3/sqlalchemy-2.1.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:06ef75e3e0c72d774b41f659bfc4f63d5e207d6c8418abe2b80843a4a25e2fa6", size = 2496326, upload-time = "2026-10-03T02:48:37.363Z" }, + { url = "https://files.pythonhosted.org/packages/2b/6c/6192d30fe24edc7c9e98c315341b7342ba7168d1729867c5d87e56285658/sqlalchemy-2.1.3-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70e05ae07dd9be29d4723bd0c880df622387eb51efb46a7b8efd49295248ffd7", size = 4880284, upload-time = "2026-10-03T02:49:10.221Z" }, + { url = "https://files.pythonhosted.org/packages/40/11/a758ae0116ab663f3520970cd870da5616cbb09c369b843239cce50696d7/sqlalchemy-2.1.3-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10fdff11782842696172489bd4861ae63c212e62ee7c4fc61fdda90961abd1f3", size = 4830599, upload-time = "2026-10-03T02:54:03.353Z" }, + { url = "https://files.pythonhosted.org/packages/8a/e8/faa500255fd90f421ef9e918d522aa4f02212b53859c35dadbafb3532bb2/sqlalchemy-2.1.3-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:edd3e6690f350c89c616e78e9b58833c3d8925607ac5faf3530f38f053f58e4d", size = 4481272, upload-time = "2026-10-02T23:59:15.592Z" }, + { url = "https://files.pythonhosted.org/packages/3c/4d/26fc9adb3fbc1d5fb8b92043a1639fd111761b8c52a2ce61df8b00ff405d/sqlalchemy-2.1.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:e3c165df26eca3d2612f60ea91af02648cb1bd492eadfa64dba15c9a3a0fb09c", size = 4737553, upload-time = "2026-10-03T02:49:12.413Z" }, + { url = "https://files.pythonhosted.org/packages/08/41/3639fc1d9fd7ea201ed4941215c14b87ce3c3b5f5f0190eb93321d673e48/sqlalchemy-2.1.3-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:70c073d28d043766eb1725010792f302f59b14636ffef478793045471efe92dd", size = 4479761, upload-time = "2026-10-02T23:59:17.572Z" }, + { url = "https://files.pythonhosted.org/packages/47/c8/541035b19b34dd581fb24b6fcf065db6fb4f210b9fbf93e1b2bf5f8cd5c0/sqlalchemy-2.1.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:bf5f63229512f14e7bffc9a0041386318f592e76bbeaf9b750b39056094d91f3", size = 4757307, upload-time = "2026-10-03T02:54:05.524Z" }, + { url = "https://files.pythonhosted.org/packages/40/a3/bb51886c02a60f1b7fb5461a25d6a3e57ce0557b916032dc3c7a72fae226/sqlalchemy-2.1.3-cp315-cp315t-win32.whl", hash = "sha256:697942762c40e31eb0fa39bf0e58e1ac1b5b1ff9022da62b74f7cdc53096a482", size = 2433533, upload-time = "2026-10-03T02:56:52.298Z" }, + { url = "https://files.pythonhosted.org/packages/e5/8e/1739b378a2b40bfc5541355c9d944b96dffcbe86aa1ed3aff31ad13f5d9b/sqlalchemy-2.1.3-cp315-cp315t-win_amd64.whl", hash = "sha256:a9c33c0d081a9d2c94229b9ae1834cb2ef81dd81253df4763e32111287b64c31", size = 2499211, upload-time = "2026-10-03T02:56:54.801Z" }, + { url = "https://files.pythonhosted.org/packages/5d/9a/ce4ea58551258203381017bc4724369cc8948ac5a46212671266b13424c4/sqlalchemy-2.1.3-cp315-cp315t-win_arm64.whl", hash = "sha256:c36ff11196dacf50312a2cc51a798bb41611331c174871f065be651a838a5361", size = 2418086, upload-time = "2026-10-03T02:44:11.611Z" }, + { url = "https://files.pythonhosted.org/packages/e5/12/23d8fb7ed90e5b674006ace415ede1c60cdfc38ace50968b59ef42c22754/sqlalchemy-2.1.3-py3-none-any.whl", hash = "sha256:6f0cf4debd86cb5623a50310784bd005c215761ca7f618d0d4f46193d033675a", size = 2052603, upload-time = "2026-10-03T02:34:19.115Z" }, +] + +[[package]] +name = "starlette" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7b/2b/3850dc6bf7ef71b088962eba31dafc6cffd2f96e577ebb0bb316df96da3e/starlette-1.7.0.tar.gz", hash = "sha256:c79f74ea63cff761804fbbfb182f1e0b440c2d07b164d24700c5a1bab5d6ff5d", size = 2736246, upload-time = "2026-09-23T07:30:26.35Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/d6/1ec1b290f9e0fb067899b61e1d37a30c923068bad260b216dbe37a7d2967/starlette-1.7.0-py3-none-any.whl", hash = "sha256:67f8e99895493dd2911a03f11314af6ceebeae4e704bb9f43dfc6a9db151c93e", size = 78980, upload-time = "2026-09-23T07:30:24.567Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/31/3d74fa778a63b98b7374323befcc0be5ab3bd94afd4096a0124e7379152c/tzdata-2026.4.tar.gz", hash = "sha256:f1b8bd365d8d210c55353f4d7f8d6d8561c0ba50d704b700d195a9424bba0d79", size = 199350, upload-time = "2026-09-12T12:56:03.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/bc/8737e8d54cf51106118039b83f485a4783112fab49ea9d044b234978a46e/tzdata-2026.4-py2.py3-none-any.whl", hash = "sha256:c2169a8b0a7a5e9674da5a135ccdfb2b3e671b333ed9fed17b41f73c34476e81", size = 347494, upload-time = "2026-09-12T12:56:01.67Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.54.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/34/30e9280707135d2cfc589dfff3cb796bd07a3aeb1a3e415ba09dd89d7bb4/uvicorn-0.54.0.tar.gz", hash = "sha256:a2e33cbfaa0306f8e6b0c13e0cb89d7d7a2da3e62b90c66e18c33d9807b28620", size = 112283, upload-time = "2026-09-25T06:52:37.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0c/b54a4fdd7f90a3af8b02ebc9ce6712c2c208b7926a2f7bad95c33ebbe943/uvicorn-0.54.0-py3-none-any.whl", hash = "sha256:505bdb0f318731d45f1f712071fc781a8981f6847a31c902c9f5e652d4f67faf", size = 87427, upload-time = "2026-09-25T06:52:35.829Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/42/02c739ce85fb2ee8d99212c61417da8140c6b87e9d97c430bea520d76044/uvloop-0.23.0.tar.gz", hash = "sha256:28d160f51ab4da3b187063652e643dea6831072add4adc1e6d62afbe73b6be27", size = 2559185, upload-time = "2026-10-01T03:17:04.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/83/eb980d64e6dd5da46d4dc35755fa6afd6b5b47141437cf89615f1117c5a6/uvloop-0.23.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2dcff2d69be43e6559e5dad2c5a7a2dbfb60e05a77311b6c4b7a4a8123d86c65", size = 1412726, upload-time = "2026-10-01T03:15:52.49Z" }, + { url = "https://files.pythonhosted.org/packages/04/c1/02a725e7698134c647904bdee6589e2be14a0e7fc9942c74f86e2b90d48b/uvloop-0.23.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:19c64108b507cd0bc140e400e3396bacebd9d504956aa7726272bf6de7d9aabb", size = 779071, upload-time = "2026-10-01T03:15:54.02Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1d/cde53c79e8c01884ad1cdca8e407e086d523362cfe4139e2c2a8dde27304/uvloop-0.23.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1748321e3c59a14a75404b1ae8d5a8d81c4e201803ea0e14c1b6fd84421024b5", size = 4395323, upload-time = "2026-10-01T03:15:55.549Z" }, + { url = "https://files.pythonhosted.org/packages/98/54/b12915bebbf99d7ae0796211e7f5977b95f069830dca45dc1a346d84125d/uvloop-0.23.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2cba180d6451822763eda8364f342435a873bcfb3849cbd82fdeca248ca65eb", size = 4480449, upload-time = "2026-10-01T03:15:57.362Z" }, + { url = "https://files.pythonhosted.org/packages/f7/8e/da6de68c31549a052a105fc76f5a9a204f6df22cb0909440aa4dbb06f9a2/uvloop-0.23.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:dc61e4f9e37b507069dc7e659ae28bca7adcb04c993c3508214315d12c63f848", size = 4219177, upload-time = "2026-10-01T03:15:59.351Z" }, + { url = "https://files.pythonhosted.org/packages/a1/c3/1b53c6a89dc9c9d5cb75eb9a0b891ad69b32e1421ad3aa01617a9cbdcc78/uvloop-0.23.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7337b06a9f9ed9ea3049f04b76f65819db9b19bb832ee598e97b388eadf25e5f", size = 4346132, upload-time = "2026-10-01T03:16:01.064Z" }, + { url = "https://files.pythonhosted.org/packages/4e/a4/00e85345871c59c834a23c136c1771205856028ecc8ba940b3951178e59b/uvloop-0.23.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:b90397a50ad6332ed3e459c648ac20d182cce24a557354363ad85fc9ea4a17cd", size = 1421363, upload-time = "2026-10-01T03:16:02.599Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a9/e5f0f3cfde30af3ec32eba8ec07bccdba2b5116afbd1ecc53edfeb0a0790/uvloop-0.23.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:be53e1d5f83de43dc175c87612ecc128d444b38e5c56cb3f807f5a73d6887476", size = 785177, upload-time = "2026-10-01T03:16:04.018Z" }, + { url = "https://files.pythonhosted.org/packages/9e/79/9ddf78f8cd75a15c14a09a57f59c587b8cd9d82802c5c8368b9c3ebefa0b/uvloop-0.23.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b3cbc4f96ddfa1fb88a78a69dd851369825b7816d9702eee8c4461505ba172e", size = 4381060, upload-time = "2026-10-01T03:16:05.642Z" }, + { url = "https://files.pythonhosted.org/packages/1e/20/57d63c44d32326878fcad5c63854afc9deb394ed95673c1b1a429178c79d/uvloop-0.23.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:31e0cf90bc8fd88784f6802cdba968a51fb1aec1cc3feec74d862b2d371d1330", size = 4418891, upload-time = "2026-10-01T03:16:07.326Z" }, + { url = "https://files.pythonhosted.org/packages/12/c5/0795abecda2cc3dfe41033f880a32a9ff103be4e6b177ac736833c153a0e/uvloop-0.23.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa8ed556fcc87a4091cf61587ef172fa104323dc89ecc085a618ba7ff8629a8f", size = 4214811, upload-time = "2026-10-01T03:16:09.13Z" }, + { url = "https://files.pythonhosted.org/packages/20/18/9010dacd5221eec1bd79a4a83ac68f3db6a42d7bb657f7b640c4838ca6b6/uvloop-0.23.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f3fbfe82829d8e381426a289b87e59e585278728361db9ce975b88b51f64f410", size = 4294876, upload-time = "2026-10-01T03:16:10.875Z" }, + { url = "https://files.pythonhosted.org/packages/b1/08/f6384a03c771d00067cba4f542a69b2fc1a982e9fd78b357c2f788678d72/uvloop-0.23.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:7e35c9bc977760981693e1a7a51493b58ee5a501f9ebb1e547565ee40b6c6208", size = 1494811, upload-time = "2026-10-01T03:16:12.399Z" }, + { url = "https://files.pythonhosted.org/packages/ac/01/756a4fb24a449f313cf4a153eb0c6210b49cfe5539255ec9fb1e17d2c4ef/uvloop-0.23.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5bb9be71d9ee39b4359b832f9569518ec9bc08704194034e79e4958e6bc4d46d", size = 819396, upload-time = "2026-10-01T03:16:14.094Z" }, + { url = "https://files.pythonhosted.org/packages/3e/45/e314b0c600b14f53dad3a3c2d7a922a249a88225fd727652b53e1854b9dd/uvloop-0.23.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e84575f11873c109cf3962ad0bdf679094466184125f4cadcc41a73febff41f", size = 4734966, upload-time = "2026-10-01T03:16:15.815Z" }, + { url = "https://files.pythonhosted.org/packages/66/0d/8686a7f0b1b2d55ebd770ba21f8e0e4ffa0cde5ab738f43ffb8264499052/uvloop-0.23.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbbdb8fcd5e7062e546eec1ac78c28bb21ae7df54c18f8e4b06e15a18d661a49", size = 4584963, upload-time = "2026-10-01T03:16:18.198Z" }, + { url = "https://files.pythonhosted.org/packages/78/b2/034a2d47e435ac02357c42956246887167bdc0357bdd6ad31c5f6d94497b/uvloop-0.23.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:76345f51367fb1f23e08605c6efb18374f669be5b223658fbab6b17627950507", size = 4421388, upload-time = "2026-10-01T03:16:19.953Z" }, + { url = "https://files.pythonhosted.org/packages/f0/77/131f4b583e6b4b715c404a66b51c812d701db20f25c9018b188a2b00062c/uvloop-0.23.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c7ef4701a96553514b2688e342ef1bf2beae6cfd172d89a76c768292aabf405", size = 4402414, upload-time = "2026-10-01T03:16:21.716Z" }, + { url = "https://files.pythonhosted.org/packages/58/3d/ee11f4718ea1280595c67ed25c83d4c92115dc100bbdfd192d3ed9339168/uvloop-0.23.0-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:f1341c6abcee1c31277cfe28d34e46196f2143ec3d755e6efe7452126e1f626d", size = 1418095, upload-time = "2026-10-01T03:16:23.241Z" }, + { url = "https://files.pythonhosted.org/packages/f8/0c/7ca516a0671418517d79a09d3ff2ccbb44af94c75711afa6e4cf58aa6f65/uvloop-0.23.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:e095f9e105af76593b4c183bb0bcbdae64bd913a59ec595732dc108b48730ab5", size = 784837, upload-time = "2026-10-01T03:16:24.666Z" }, + { url = "https://files.pythonhosted.org/packages/35/95/75d4e28e596d505b7ae11de517646b4ca3d369fb8537ba755410380da11a/uvloop-0.23.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f673d835bdb1a60229cc3609a113fd2c9ce3f4a3c75ad4eaed111180c00199d2", size = 4380276, upload-time = "2026-10-01T03:16:26.389Z" }, + { url = "https://files.pythonhosted.org/packages/10/99/68daf827ad62efaf4667d1f3fda127046d42161178396bdd93aab3684082/uvloop-0.23.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c3f23f403a273900d57de6ee5ca0614c650f7f58563065dad1a4744498960e53", size = 4451496, upload-time = "2026-10-01T03:16:28.364Z" }, + { url = "https://files.pythonhosted.org/packages/71/69/f67e696ee688f426a96f99099bae26fec14a1d0fa75dccdd6518ee267c0c/uvloop-0.23.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:cbe8d03d4efcccdb7fcedecbaa1e1fa02913eaf3a74cb933634a6bc6d2ea9e2a", size = 4212541, upload-time = "2026-10-01T03:16:30.014Z" }, + { url = "https://files.pythonhosted.org/packages/f1/6a/c8c436a9d7453297b4be70bdf6a9f9fc9400da45e0059ddf7b28ab63f4c7/uvloop-0.23.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:4f1798f56c6f4ba5ac11fa2869e5717926e4470d97a1dd42b4f59219d43b5027", size = 4319377, upload-time = "2026-10-01T03:16:31.705Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2c/8fc15a03489299aab8a6212dfe0f137dc39836f915c87f7fd9d9ddd814de/uvloop-0.23.0-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:098a85e1393ef5202767b7e5fb41a32cd8bd81e6ee4af364c179801c4aa3f6d4", size = 1493428, upload-time = "2026-10-01T03:16:33.859Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7c/05e4a210790229607f71460fcb2ed4a2c7bc72668d8a928ce577c22e38f8/uvloop-0.23.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:5a2bbad3a63007f7e9524d4903ba04fee252557c2acd86f9a3d4f91786695254", size = 818115, upload-time = "2026-10-01T03:16:35.45Z" }, + { url = "https://files.pythonhosted.org/packages/65/14/a40b11c6c024213803b13955664a15754c72f64c873a33d986b26ec9ff5b/uvloop-0.23.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a08875543bbd4519faf30497506c9cda8a48470467ffdf967c7313c7a5981a8", size = 4734149, upload-time = "2026-10-01T03:16:37.025Z" }, + { url = "https://files.pythonhosted.org/packages/9f/83/f421a077712c1e87603bfec62744c3cd3a2f4b47378025db3d740df9af0d/uvloop-0.23.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:12634f15e6625f78b3f2922f91404c4d7173487eba11746764153f556e9852dc", size = 4661763, upload-time = "2026-10-01T03:16:38.719Z" }, + { url = "https://files.pythonhosted.org/packages/f5/62/25dcaa6b7e7b48f82ce633854ce96597ab768f9650931f4f86c572de392c/uvloop-0.23.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:378188efbb1524f2219d05246a3e1e5907217848d2882144dff59585f1b81d55", size = 4421324, upload-time = "2026-10-01T03:16:40.488Z" }, + { url = "https://files.pythonhosted.org/packages/05/46/04628239b43dcef703af314202a3307d6060918e2d76aa86c5b1188f5551/uvloop-0.23.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:4b8e207c67d207a8608fec57e116511030af3495dc0109b8c333cf9cb412b16f", size = 4462501, upload-time = "2026-10-01T03:16:42.359Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/68/e6aa0b77d217b31f8f486ec0cdfe5e00e6e38dc0be657e7d85819b9faf0a/watchfiles-1.3.0.tar.gz", hash = "sha256:99aee4a07847c06820765fd7b1b49ceac4f3f711ccb7d104655a33231de1c207", size = 108119, upload-time = "2026-09-21T09:08:55.664Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/fa/c0b840d5d8bafe640925408ce11095948c8945bb61be1e67d0ab629b872a/watchfiles-1.3.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:000b9688fc8133037a8b075c8ebf98f32844ff8964dda61e85c1db547dafc441", size = 399703, upload-time = "2026-09-21T09:08:13.518Z" }, + { url = "https://files.pythonhosted.org/packages/c7/8a/894799b485fe9473ad10422a0d9668e53fb78e3a2b5cc6061159572844ad/watchfiles-1.3.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:bbc1198edfdc90fda0600f825aa94150f428dfcbf8138746f55998e0e660d64c", size = 397647, upload-time = "2026-09-21T09:08:15.16Z" }, + { url = "https://files.pythonhosted.org/packages/5a/61/f277cd5ead05f5d37b98b41fbdd0c943ee0c04d49738f62e902cb291cf43/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4af9c464d410e8c44b58ddf1cbec7c3a02c1cb2c6c80df9c74659ba2fbda0ab5", size = 454994, upload-time = "2026-09-21T09:08:16.587Z" }, + { url = "https://files.pythonhosted.org/packages/46/66/ea4c01382975e53b1ac1b0a0ea1b353416b8127fb6e04f5c1c313119aab6/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb94e40b9a0db19636b5e3fed0a9803452ecc7381df13b385272abb779ee83ca", size = 460105, upload-time = "2026-09-21T09:08:17.961Z" }, + { url = "https://files.pythonhosted.org/packages/31/c3/1a501b597817096c9831c255f6270c124ce0e2faeb74124be24dcc93b86d/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7398930a2b76b9dc67a4e6b8bfc7baf30946892a86f417a3920c07bdd6febc30", size = 493761, upload-time = "2026-09-21T09:08:19.327Z" }, + { url = "https://files.pythonhosted.org/packages/5a/16/77352d05e152c7786a016e9f1decdb4507e8aa5bcf3343c72142a0413d14/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:025b108f2d5cc2799cb941f79380df3e8b22bfb589c218e801767fe78d598c95", size = 577500, upload-time = "2026-09-21T09:08:20.723Z" }, + { url = "https://files.pythonhosted.org/packages/95/fe/81001c55b24466fe9911135e50269fd3a29f43e446627a92adfab54553b6/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:be9ef3cd403d756a304a0a08a112163c63da7ec72c8c9d083c98818291ff8f29", size = 469209, upload-time = "2026-09-21T09:08:22.346Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cf/e3ad894ed5909a7c44b0707e89bc25d19a3d78b3a7aec846119443b6ae56/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b5768b49e426fd5b550b012c866db347cdf15c398ef98dc557b6e6b72fa74cd1", size = 458353, upload-time = "2026-09-21T09:08:23.7Z" }, + { url = "https://files.pythonhosted.org/packages/7f/f5/2f106829e6e2bc91ad74614730f6df484202b9a4d20d927dbf2f08d37d44/watchfiles-1.3.0-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:d36c72fcdc08f143d87316a4c4c3b064e340da2c7383d39803950740ed418935", size = 468707, upload-time = "2026-09-21T09:08:25.167Z" }, + { url = "https://files.pythonhosted.org/packages/15/37/2741ab776b978669c1f3bba1e573f1a184f727ab8b4d3a6d9c61090800ed/watchfiles-1.3.0-cp310-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:f0c9865240e065a2247f4b5248528ae3d01e0e8ae071462c250c884dc16e3ee3", size = 632510, upload-time = "2026-09-21T09:08:26.532Z" }, + { url = "https://files.pythonhosted.org/packages/48/64/db4a4684275fe9b696ee5118c194705c1423b3d42181c3f8ee70194d5c73/watchfiles-1.3.0-cp310-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:380f513d26cc2e598266b88d66456e07d67ef4be8f0f435a1154d9c44b43d509", size = 661723, upload-time = "2026-09-21T09:08:28.004Z" }, + { url = "https://files.pythonhosted.org/packages/52/80/95ddb24f6a2f595c031c24432ab7b8aa7d6d1ada0c6d63a552166e06e8d1/watchfiles-1.3.0-cp310-abi3-win32.whl", hash = "sha256:509d9f74d2bec5c1f4cd868ddcfe0c9601f7ea53066dfcb19d8ede742c4de661", size = 277761, upload-time = "2026-09-21T09:08:29.335Z" }, + { url = "https://files.pythonhosted.org/packages/48/a1/1c4a1b3c8030cd03206232612be8a6e8799093490c220b0fcb935d02b75a/watchfiles-1.3.0-cp310-abi3-win_amd64.whl", hash = "sha256:1acabde19b67e673274e89a04d613b7fb1f122e2c4b8ec9a581e59c149264b61", size = 290643, upload-time = "2026-09-21T09:08:30.45Z" }, + { url = "https://files.pythonhosted.org/packages/82/e0/d2db577c632d26d08078c4390dd870b8555c4e558df25d22270f0166c71f/watchfiles-1.3.0-cp310-abi3-win_arm64.whl", hash = "sha256:d978cc1dd7ba44f5590d7de74f7a9c8be7262d9e469c2c0efb8bbfc2574321dc", size = 285510, upload-time = "2026-09-21T09:08:31.561Z" }, + { url = "https://files.pythonhosted.org/packages/4f/55/a122743607939429ab6a7272672f49303aa02bbd34c3f06676765a3861de/watchfiles-1.3.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:e0204f90677b7fb8d3a824279a71a01166bea9f6e53923b8056592d87b33b0b6", size = 395978, upload-time = "2026-09-21T09:08:32.747Z" }, + { url = "https://files.pythonhosted.org/packages/3e/9d/0ee1b0ea42acfe72afa2484f0222e2386dd09992e9034a81e63cc3cdab0a/watchfiles-1.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7c1125e99f84934a92996ae343501285617373acd66f16b59fd5654b27ad8d80", size = 387997, upload-time = "2026-09-21T09:08:33.953Z" }, + { url = "https://files.pythonhosted.org/packages/b7/f2/4319c6b683e8bd28465dfc53c750617a2a07f072eb2e3ab0182ccf812229/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a85e243c87109d9f48a2e0d10d6abdee8768cc114848b32281ec83d7f81d86ef", size = 448067, upload-time = "2026-09-21T09:08:35.104Z" }, + { url = "https://files.pythonhosted.org/packages/b7/71/9f37ac192a51a24de3a2d7fe9aac1e99b2b2668300674ff7a9f85d95beb1/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:dd538c71766c59c732e2c5cedc39323c99c11897b99f93eac773438e54af206a", size = 452881, upload-time = "2026-09-21T09:08:36.238Z" }, + { url = "https://files.pythonhosted.org/packages/34/07/1224667c32a4a96876ddd642da69733cbfe6a15897db0865aec5a882cebc/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:da213822ec9082b62cadf3008e07a8c690ec7a3c89f6dc5e55a8cf07b56a1b26", size = 484802, upload-time = "2026-09-21T09:08:37.717Z" }, + { url = "https://files.pythonhosted.org/packages/bd/4d/7f29cd87e1f826df86fc0418d0b8f370ca0311c29c618406dc492aad9951/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d4593392e87669836670f87d62fc754059eaeb0e6157af44060a46558cebe5a3", size = 567522, upload-time = "2026-09-21T09:08:38.94Z" }, + { url = "https://files.pythonhosted.org/packages/e3/ac/3aa1f84abd4dbbfbae99a817dd931f77b439261c911189cad4c30fdfd470/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:26b81bc515a0f03f66a69a6fea86eea54c8eca4173c46dd7222569f79dd2f977", size = 461997, upload-time = "2026-09-21T09:08:40.142Z" }, + { url = "https://files.pythonhosted.org/packages/82/1c/27c66764d5b0dbb70a36770a32eca217eba739c28b24e8583a035087a3d7/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca39661934749df580d89f3dde266f2af7c6968f4c21c72ce2dc387d299e6aa7", size = 451856, upload-time = "2026-09-21T09:08:41.305Z" }, + { url = "https://files.pythonhosted.org/packages/1e/b3/a45827084d953c1d0dd3d75abf85dcac26a0f5d115ad2fe0b7e9e931573b/watchfiles-1.3.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:04c5500faa725d69a99b0f63750e80795e455bb951849e9c98a811b4324997c4", size = 459889, upload-time = "2026-09-21T09:08:42.58Z" }, + { url = "https://files.pythonhosted.org/packages/7a/3d/07740d23ce2bb23419bb24055c435a9abe5fe03efdaea1ba0720b831aab6/watchfiles-1.3.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:ec3cd4bb181a7b6329134204c2277c7144ab5f62cce9c5335d6f901ee7ce8d6a", size = 624482, upload-time = "2026-09-21T09:08:44.064Z" }, + { url = "https://files.pythonhosted.org/packages/91/46/9540f8123eaec86df989f26d7704cad0448d9fb7271763a4f8f9fe23617d/watchfiles-1.3.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:e5871d4a7f788a9f64fb06f5793389d29d171e5c32d37c3ebfd520b017cc7694", size = 653724, upload-time = "2026-09-21T09:08:45.257Z" }, + { url = "https://files.pythonhosted.org/packages/25/9d/b9deb52f1e4528ffe44338c5ebab95c32ed79203082da661de50288e130a/watchfiles-1.3.0-cp314-cp314t-win32.whl", hash = "sha256:81c989d7267bfb7b676cd32c83bec97914e396870983cbf493e29415037bff5e", size = 270863, upload-time = "2026-09-21T09:08:46.518Z" }, + { url = "https://files.pythonhosted.org/packages/59/59/8b1881afccbafda06248be4185e28034dd73c80c85e4da24f2cb0c1321eb/watchfiles-1.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d754e049006e81a9be49dc78ba7802081cee426e968cbc63ee19e164fa2d0e39", size = 286879, upload-time = "2026-09-21T09:08:47.874Z" }, + { url = "https://files.pythonhosted.org/packages/46/dd/41c12ae9745d5d72f709333287679d71cbba68ad7678fed872207bba94f4/watchfiles-1.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:a5b631db08fd3032db57ec6a92d1777cd60f88803287e4e941a4858c53aab1e0", size = 276924, upload-time = "2026-09-21T09:08:48.976Z" }, +] + +[[package]] +name = "websockets" +version = "17.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/72/fba934cb3dff7a85d811820efffcd141ddd52b5a2a01637f64551373ff4d/websockets-17.1.tar.gz", hash = "sha256:acfea4c20bf54384883ea33b1240fc1db4f52e190823a4e2b334bc3e8bfca96a", size = 187520, upload-time = "2026-08-26T17:25:33.063Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/31/5f6450a7879f4f063ef08897cc385ea3ce3f1fe17f08b11e3fd959abdf27/websockets-17.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2a0162a6372110a5601cb5c9fd826635cedf69f3e110c545dd19774e040b970e", size = 217006, upload-time = "2026-08-26T14:56:10.509Z" }, + { url = "https://files.pythonhosted.org/packages/d0/2a/c1b006fc861695d2aa4e35327b842015ce1d98cf8f99241829b3d6460bfc/websockets-17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:829dba1bc049779de9b332088c1a6a9858e96bd67e50b6b644a95e02b67836bc", size = 214690, upload-time = "2026-08-26T14:56:11.681Z" }, + { url = "https://files.pythonhosted.org/packages/46/69/66e5b7d01445e0eeb1d4ab419c30315f2c90cf7a8a8cd4ecc47f894dba54/websockets-17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fd8f47dbf2e8adb15c847215f83436de3fdb120b51fdae0fbbdf69fd97a3ad80", size = 214947, upload-time = "2026-08-26T14:56:12.923Z" }, + { url = "https://files.pythonhosted.org/packages/07/ce/033cafe2d2538562efa876b9149a2c7a0f7787870a4b1bb6e28adc9ceb6b/websockets-17.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9f4c0377a83e163a303514fdfab501dbe379bdc13e5b9312a91d112658b29dce", size = 224329, upload-time = "2026-08-26T14:56:14.212Z" }, + { url = "https://files.pythonhosted.org/packages/34/c7/e1c2e8a67f6cc0aa43abe0046fb3b7a020980649e6a843751dc7ce9eb170/websockets-17.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c3241d684a76eaaef8b2dc789afde4343cd3aad55ea81e4e8ab3605b529bae51", size = 224611, upload-time = "2026-08-26T14:56:15.702Z" }, + { url = "https://files.pythonhosted.org/packages/be/de/07c6d48eb3d2069709410c851e7de10ab83d752c4bd09862899627c2729b/websockets-17.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e5f5c7a893507d0e83a80b88aefd6522f7e882cd53f9722c6f23f5a020c9557c", size = 225848, upload-time = "2026-08-26T14:56:16.962Z" }, + { url = "https://files.pythonhosted.org/packages/f3/dd/3c68572d20509648cc2fb6f50ccf3deeb4b87270f2c8966e99476e278ea3/websockets-17.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:00bf34b64501e3477e81fc281532ff3cbf4da26633c10b63979d5085d46602d3", size = 227290, upload-time = "2026-08-26T14:56:18.204Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4a/8f6651c8a22093539c9215af0c5bbf217b87b382c99d2112039b92d593c2/websockets-17.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ce0305b702b20d1e1d60a9aaace6bc89970e1753565543f310d549eab22c2435", size = 226476, upload-time = "2026-08-26T14:56:19.459Z" }, + { url = "https://files.pythonhosted.org/packages/f5/be/f6fc33cea86b1127fd1297b18c107e81580ab55a73a39f9a934441ef321f/websockets-17.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29176d8b429cfa0fa443c473878d37a5c06cfd0cb36b71ba4314accc71e05906", size = 225233, upload-time = "2026-08-26T14:56:20.939Z" }, + { url = "https://files.pythonhosted.org/packages/cb/83/65edaf05f7c9b1dea82f4d252fdc37706a84571646f06119a27b0a16fe19/websockets-17.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3709a1ab30b4b922027d22f68d2b61a0656a91680ac894a537624e6be7dd7f7c", size = 222488, upload-time = "2026-08-26T14:56:22.208Z" }, + { url = "https://files.pythonhosted.org/packages/07/42/d1169c2f7f1f0032b0d4b0c00f0711a070cd7c735de37bfeb876bc0f9606/websockets-17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:43bd0c1ceb924d67f5c1a5254d8361dd9d94246e6331a726064dfa2917880780", size = 225295, upload-time = "2026-08-26T14:56:23.445Z" }, + { url = "https://files.pythonhosted.org/packages/a6/f4/64e2a386c3899b917c2933225c9b47887874229d159797f3bf1a11c20d51/websockets-17.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:1fce0f43e0d41422e0b2cad6561e1970df22f212f4c7e884967df7cf591b031c", size = 223891, upload-time = "2026-08-26T14:56:24.647Z" }, + { url = "https://files.pythonhosted.org/packages/26/b3/dfb5c482f7e310a3432fdbb045ddfe6d34114680e89a233d4ff900a32961/websockets-17.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4031152769179ab8dcdeafc7b0e58052a49117560a28671700b47b2c7b717aad", size = 224661, upload-time = "2026-08-26T14:56:26.027Z" }, + { url = "https://files.pythonhosted.org/packages/a4/cf/94865130a336029f46412adc127c4fbe380f46172b90ce251369e35c4302/websockets-17.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a06f3b5085176763182449559e20391d7ce616a8972a9f7a33deda87ea6d4f3c", size = 225766, upload-time = "2026-08-26T14:56:27.455Z" }, + { url = "https://files.pythonhosted.org/packages/96/34/eb8c658f86dfe562ed49a887a27424bfe9e618c26ea6f865b093d075d3a6/websockets-17.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:77b37cceca17291897c3c73bd30a7c7c7909593554b5da574ec852af83c1742a", size = 223323, upload-time = "2026-08-26T14:56:28.807Z" }, + { url = "https://files.pythonhosted.org/packages/1b/7e/2629609652ece5ca0c7ac235927dd4511b08131e3a5d53439b798fddf002/websockets-17.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:d8e83333385cac6030a5167fd18bf96cc6c58b914c308e683f05b0cf94bc8dd0", size = 224276, upload-time = "2026-08-26T14:56:29.991Z" }, + { url = "https://files.pythonhosted.org/packages/a1/6b/8525737fe840b38e5f40956c198fb586a4fac1e07144d41a5b949b989cf8/websockets-17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:073c5c3f7e127041fa9d34a9e29ceefee8c3cafbd267ed2927318f425144380d", size = 224558, upload-time = "2026-08-26T14:56:31.184Z" }, + { url = "https://files.pythonhosted.org/packages/74/ab/3a958c6cbcf74b118f601c20a80ac8bd5e8dfec0bcf7345116feaeefb121/websockets-17.1-cp313-cp313-win32.whl", hash = "sha256:2afb58c7ba48b329d56769f8dfd89f394efe587b65ef806bae810a484d6d3608", size = 217475, upload-time = "2026-08-26T14:56:32.431Z" }, + { url = "https://files.pythonhosted.org/packages/22/36/fb521f0f2994c25509651f169efe5582dddd8713d57a0757ba87859372ef/websockets-17.1-cp313-cp313-win_amd64.whl", hash = "sha256:0340bbef6bfbe16da888b3983d666a4db4954ac3253c38f13bc7aba0c7db5a2f", size = 217784, upload-time = "2026-08-26T14:56:33.608Z" }, + { url = "https://files.pythonhosted.org/packages/68/92/9b8419584681a12a7534b746dfb2737c466efe2455483e2fbf8b941a04ec/websockets-17.1-cp313-cp313-win_arm64.whl", hash = "sha256:7a72efa3bf4fa3a6669a54420a472ad056da3973d827f10e3a536da463f926c2", size = 217715, upload-time = "2026-08-26T14:56:34.865Z" }, + { url = "https://files.pythonhosted.org/packages/90/0d/500cf5daea09d4669dff3a7d67159094a0bd6c4ef130381404f6edd3eb5f/websockets-17.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0c9982938980e086da59f70d05f9418cd143401a601a0faac10fa48f7bb1cd3e", size = 217048, upload-time = "2026-08-26T14:56:36.03Z" }, + { url = "https://files.pythonhosted.org/packages/97/12/5b12c6168aa269cffbfd24d177cd492b130120403a418c7e89462e27b4ac/websockets-17.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:57b39dc8541cf7ed3f639da82bf7451060483967f9e733da1f8173e4095f0642", size = 214737, upload-time = "2026-08-26T14:56:37.43Z" }, + { url = "https://files.pythonhosted.org/packages/0c/36/e453e5106e4e2416f008ac222837c2f1637a063b08008afcd1088889b631/websockets-17.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:96abdecbaae746851b87c3a36cb4a661df93ca3d92f114270f79228bf1d00de6", size = 214955, upload-time = "2026-08-26T14:56:38.71Z" }, + { url = "https://files.pythonhosted.org/packages/dd/30/0204bb86176db02cdfc678ce65ed808a66fab87d250ce61a8790800a60b0/websockets-17.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d9fc873e239c5abeb150bc24dbd1a7af23a9254526383ce0a077f5e20adbeb19", size = 224331, upload-time = "2026-08-26T14:56:39.924Z" }, + { url = "https://files.pythonhosted.org/packages/46/c8/d8372256e00c4e3cab1115c45075d1eeedb642a3f2b42bd70c4deae03f06/websockets-17.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6f42912fa9eb4cb7c7ec9fde9b3332ba339eb8a8811981043d4029599f3d950b", size = 224685, upload-time = "2026-08-26T14:56:41.169Z" }, + { url = "https://files.pythonhosted.org/packages/12/7d/650355b8f67f908ff99603351d4458d1a0b787d627950a47c38db7e25308/websockets-17.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f98bf378d7a5be047a044a1a27c987a8f355e10e3b5754617dbe756248cbc5ce", size = 225927, upload-time = "2026-08-26T14:56:42.359Z" }, + { url = "https://files.pythonhosted.org/packages/34/6c/a9ffa5b903579eed76017870f055d75ecc73988d9d0c9b65a92ba0bf2a27/websockets-17.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d334d11398086bb5559606cb42d51c013ea7c061c7db701521392373d3c087f5", size = 227300, upload-time = "2026-08-26T14:56:43.538Z" }, + { url = "https://files.pythonhosted.org/packages/9b/5d/4551c2269066af7481ee44605a0813770961615b5b5da3e87a8f5cb859ea/websockets-17.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c27336b1a0ac56569493e858497870347854372395f50483725f8cdacc5a45c", size = 226533, upload-time = "2026-08-26T14:56:44.669Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/237a99233e5c445759a613831b3a92e91905afc064dc3bd0ad33c35fd1e2/websockets-17.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:67258b00302a5aaf0b267771c7014b13429abd7ea17eebc4c55bd935ff101555", size = 225280, upload-time = "2026-08-26T14:56:45.83Z" }, + { url = "https://files.pythonhosted.org/packages/d3/b5/e9407a91613d1d1cd932414143a1012096b26674a782fc55a0bd23217ee4/websockets-17.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:455ffeea0879d313205df1e745e5883e1feb7f31ecd26be882f5f0babd3db04f", size = 222540, upload-time = "2026-08-26T14:56:47.053Z" }, + { url = "https://files.pythonhosted.org/packages/db/d2/db76628db0577b783205d9779f64d8e373416b04c62d1546be4b75dc8540/websockets-17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f7233eaf441a345a5943a929fd4b5ea3278f11aed35a9ed0f3106b8cb3ca846a", size = 225354, upload-time = "2026-08-26T14:56:48.32Z" }, + { url = "https://files.pythonhosted.org/packages/a9/4c/2174181c067b89a74ae18e2650c2ac29959f4b796afe876ab3f4d30d642c/websockets-17.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c65da239a5ad553619804c1f9d65c1a0b3005381c6158ee14da2c7444cbd0c78", size = 223867, upload-time = "2026-08-26T14:56:49.579Z" }, + { url = "https://files.pythonhosted.org/packages/df/75/274decb9a8253561b5be3261e02a6676fc8ecdf31e95b722e53d5bfb8fd2/websockets-17.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9fa1ffa08c81a4f809cdab6129f8e55bee4650b9d6d3461019dda73aacd146b6", size = 224652, upload-time = "2026-08-26T14:56:50.885Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e6/49824f1fb4db7656d2f7492b1d8be16147b759d909490e32f4776843ee64/websockets-17.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:406b8107943a43ef4649b1e0cb0cdc052bbf08fe1c8905a623c4af9586e5cebb", size = 225822, upload-time = "2026-08-26T14:56:52.356Z" }, + { url = "https://files.pythonhosted.org/packages/b8/6a/5dc43838c0b02a95f42c47a0de33c5ddd7767a9feeb4d0d8777ac1cfefe4/websockets-17.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:4e8ffcb486c8490a34a4cef5e4409d8da5a1cb1681e5bf7d786ce5e84aa8540d", size = 223379, upload-time = "2026-08-26T14:56:53.699Z" }, + { url = "https://files.pythonhosted.org/packages/c2/62/585637cf06d6b321232f79c55dc14d65518d12cf87c94c44f5864068810e/websockets-17.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:fb88076df585b69c5761c387c0081aa87d7b9eb1b205a6535ca4777e25650d81", size = 224330, upload-time = "2026-08-26T14:56:55.184Z" }, + { url = "https://files.pythonhosted.org/packages/de/68/c3b234a6a1366b6ab5bbfaa4434a1b946e1dc4e8ddd6824bfd93a8835b7f/websockets-17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5d4724255fb8398acd9e583b97eb2279cec20e0bd0f9a94bf75f6056ef9f13da", size = 224622, upload-time = "2026-08-26T14:56:56.393Z" }, + { url = "https://files.pythonhosted.org/packages/6a/d4/84cf3d1376f5d8207f55f43c1c818babd6b89447f5dcd01f18a6d5526796/websockets-17.1-cp314-cp314-win32.whl", hash = "sha256:be3f0129c5654517b2abf07dcb75bb1d9479759a4ccfb569e8293579e9fc029a", size = 217036, upload-time = "2026-08-26T14:56:57.652Z" }, + { url = "https://files.pythonhosted.org/packages/d0/0f/9e7ac63c5d7cb642952200814f584318e65146df008b7d375d5d9c6b2c97/websockets-17.1-cp314-cp314-win_amd64.whl", hash = "sha256:2a4dc6ef83f4559e0d05f313a375cb38f63c986096a9da99fe94fdd779d313e5", size = 217382, upload-time = "2026-08-26T14:56:59.065Z" }, + { url = "https://files.pythonhosted.org/packages/54/bb/1ae6b91f7f3ac05f5c9f14a72dc2181c115ff370bcd8a7f10f02c174adfd/websockets-17.1-cp314-cp314-win_arm64.whl", hash = "sha256:46c0331c9eaaf73a559f3a9e388466be0df96eb83d40f06f1ca6ab6613b35c82", size = 217268, upload-time = "2026-08-26T14:57:00.654Z" }, + { url = "https://files.pythonhosted.org/packages/b3/f0/f65644d0e0b2b90918a8c41503841cc4072a58f2bf76c09bc36e751fc0dd/websockets-17.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d411ea5ca18ac1b12c0c94be88b60c18ca641ac43bcdfdf1c9f79d46cdbe1603", size = 217379, upload-time = "2026-08-26T14:57:02.181Z" }, + { url = "https://files.pythonhosted.org/packages/ff/35/4c46d1f620ac1a30f92b6eae78ee40a772a93f568647ca7ccdc5ea283cf8/websockets-17.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:07fa3e7c30e2c577928d359b56bf872a3e0cbcc15553eaa0907c1ee86344b56f", size = 214911, upload-time = "2026-08-26T14:57:03.478Z" }, + { url = "https://files.pythonhosted.org/packages/04/6e/4587e8406d7c1188e97b9cf466c081e93399380d447f885bfce81626cd37/websockets-17.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6de9acef07e3a78e9567fcd26c29011a4da8f050b13004bbf880a0fd82a6eea5", size = 215115, upload-time = "2026-08-26T14:57:04.692Z" }, + { url = "https://files.pythonhosted.org/packages/ec/06/1381c8fff525041025909eb80ace32489194a00ba22a0a8d428030afcc84/websockets-17.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ea0ed9373b880115911d9d39634bccc95b8ce590c9c42e8589f5cacc3ef3cee2", size = 224696, upload-time = "2026-08-26T14:57:05.899Z" }, + { url = "https://files.pythonhosted.org/packages/36/9d/9034e867dc85340be058619751742b895f722326e83100d110063461ca07/websockets-17.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50903d335bfda026c2fa11dd9aed09d8cbee0c451e3a85122a9acb041b7dc69b", size = 224975, upload-time = "2026-08-26T14:57:07.262Z" }, + { url = "https://files.pythonhosted.org/packages/40/eb/ed03aa3cae748ebf6397e5d44028f433f746bad09dc568ff754fda3a3c9b/websockets-17.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a74531ce81af587f906ab42f194032388fcff8fc7938402e5917c9147a39441", size = 226151, upload-time = "2026-08-26T14:57:08.524Z" }, + { url = "https://files.pythonhosted.org/packages/b1/c9/cc1964a096d16f3b73cb1ee5f14f277f5a3bcac07c6e8f9a1dcded99f4c8/websockets-17.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8fbf28e639544503b7d1c96452a5e5e043e4108d89b1f3fa02910603622d19db", size = 228292, upload-time = "2026-08-26T14:57:09.846Z" }, + { url = "https://files.pythonhosted.org/packages/1a/26/46da6dd0363c2db2e4876fd59a40fd40c1943a82d7018d0a33afbce47d52/websockets-17.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f612dc57f00c07cf4aa2673f7cbceabd654ad2457b7e639f061b794d6e11f9fd", size = 226722, upload-time = "2026-08-26T14:57:11.118Z" }, + { url = "https://files.pythonhosted.org/packages/78/98/ecd8f5e1c5d0e54c08ebc5c66852271112166db68107cb0e17ca1bf25009/websockets-17.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1c7ac77401227212dc6e849182feee50d57cf456ec6329ffd6979c94bb136c5c", size = 225451, upload-time = "2026-08-26T14:57:12.601Z" }, + { url = "https://files.pythonhosted.org/packages/65/4d/da8d2760db53e17aae763738b6ba834b1fcf16813d3632f3edb6951e1ec8/websockets-17.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32a2a68d989d6e5b74a9d5095415c51189ebae29fceb7cf2b64a1c0318a81256", size = 223003, upload-time = "2026-08-26T14:57:13.875Z" }, + { url = "https://files.pythonhosted.org/packages/a4/40/ea401c141a79c5b1d0021a0dab9d0df2051c108f1620fbb39a6e7c714c3b/websockets-17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:aec00f018d34c67500ff0438dc314b40277be4a1b983cbacbf53ccf7db63e257", size = 225704, upload-time = "2026-08-26T14:57:15.091Z" }, + { url = "https://files.pythonhosted.org/packages/e1/8e/07ab3f44215d89840d5385fdcaaab1fed8caeffa67c6899e15062957c12c/websockets-17.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:0014eaff8ad5b3b43feda2279f9d34bf2eaae040720b9fbbb55944b10f40b14d", size = 224192, upload-time = "2026-08-26T14:57:16.3Z" }, + { url = "https://files.pythonhosted.org/packages/58/93/ccf1af0a23e5748d4e22292a377d78d15cf294d7e707bbb11a8990ae6bd5/websockets-17.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:db9d7ee47f3ba531e278be539af39e2c7c7d28fb94897b6cd1120d63b0ef5922", size = 225082, upload-time = "2026-08-26T14:57:17.531Z" }, + { url = "https://files.pythonhosted.org/packages/e2/db/e32200f99ce282e728d2929f2c429db353cf3282db7d0eba99eb32c9fec1/websockets-17.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:ff3e2ba7a9f0a110b0555452e9b5a03a34e11662544e01beea15f144b48ba7b7", size = 226101, upload-time = "2026-08-26T14:57:18.802Z" }, + { url = "https://files.pythonhosted.org/packages/28/3d/e7a6e9777b29433620167c98f3caaff0d6b08b1239a273ef7f7fd1393349/websockets-17.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:6da17fc94bd270f5987b10bee113461ac36a36a98b0481ddcc98056e5a90001a", size = 223794, upload-time = "2026-08-26T14:57:20.313Z" }, + { url = "https://files.pythonhosted.org/packages/48/05/ac569090726dedd6656f3ee28b0c02dfb1ba76e898dceaccc2987a237cef/websockets-17.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:e8dc3fa6d6b7ead3f9de57895f41b116a28787548e066365d9d90f7356bcaad2", size = 224567, upload-time = "2026-08-26T14:57:21.634Z" }, + { url = "https://files.pythonhosted.org/packages/14/50/4ef62941111db6b31193f4fabbb65f845a5177579040cb8fe0d774d25034/websockets-17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b65d5fe48219dc2d5e158de9e6514e75600f379cc7e37108d35f31764c155566", size = 224993, upload-time = "2026-08-26T14:57:22.86Z" }, + { url = "https://files.pythonhosted.org/packages/28/42/2b95ada4ea19bf3a2072b68669ce4f4afb212690b727d31640576287fd68/websockets-17.1-cp314-cp314t-win32.whl", hash = "sha256:2cce251f3e2469b99b6802b55435bcdd07123b41870f54c87b336183af9d7e68", size = 217168, upload-time = "2026-08-26T14:57:24.466Z" }, + { url = "https://files.pythonhosted.org/packages/32/0a/67d5ee08dd8060a37d612fd40a625b5376ad19ae48fe1c8ad428c278b817/websockets-17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:8f6c38cdcaf98a911d7acc25577f2f9e710f3a2fc2bde1563556784320196b51", size = 217508, upload-time = "2026-08-26T14:57:25.983Z" }, + { url = "https://files.pythonhosted.org/packages/76/a3/822005d0c674451d2411027b878cdc128a2b7ea5a30d337d9e279da22eba/websockets-17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:d1e2f5fa2b6d01f0d85b4f223fea7ed1d504be282a02a81bd2be4817ef7a2f03", size = 217425, upload-time = "2026-08-26T14:57:27.324Z" }, + { url = "https://files.pythonhosted.org/packages/de/d5/99a6c6a1eb5d5ae9f45f59a3c97f4e3b21f310eb404a547fb3e7d2fc054c/websockets-17.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:88381602e379165b66244b2ebc29f9b23ea0851fbe63ae157f91ca324f072d6f", size = 216970, upload-time = "2026-08-26T14:57:28.575Z" }, + { url = "https://files.pythonhosted.org/packages/a6/0e/1e7f6e833728193958d3ed3d67b5d57c3c7cfa948abf94d4bc553257c954/websockets-17.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:88bc5138e53903a85c354e59df7ba73ce306f7b09724cef74dba121e60a88ce2", size = 214699, upload-time = "2026-08-26T14:57:29.862Z" }, + { url = "https://files.pythonhosted.org/packages/07/00/95d39549f86e34425a0412bcbe61708dd1fc46af654e2134a6c4389102ad/websockets-17.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:3546ef55b3a074494106508bc6505c73825970d2d9505f7bf53882b3e88b0d1e", size = 214927, upload-time = "2026-08-26T14:57:31.148Z" }, + { url = "https://files.pythonhosted.org/packages/4c/ff/b442415fc4f7f9943b0fc8e8eebaa13923ca73361e167c439ba634eecbd9/websockets-17.1-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9ae55d24241fc055f22aea3ac924559069848bd0ad4ea065fdd72d2194685fe8", size = 224373, upload-time = "2026-08-26T14:57:32.833Z" }, + { url = "https://files.pythonhosted.org/packages/a8/dd/b83537aae4cf61615b9d8b2dbb235c0030ba85457a6d934798273814600f/websockets-17.1-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d7b349265fad6244013eecd99df8d83c12bf3013943e431f4fadd5bffc37db42", size = 224801, upload-time = "2026-08-26T14:57:34.041Z" }, + { url = "https://files.pythonhosted.org/packages/76/83/5ab0abed58454909e8dbab45086ac68ee4556d7a8ada26735addc909b903/websockets-17.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc5789e5ea182b77a38881383ada5347202a6c66f4857d054e075290e80b604b", size = 225967, upload-time = "2026-08-26T14:57:35.292Z" }, + { url = "https://files.pythonhosted.org/packages/4b/26/e2412f2b998a8c1dfc00c0709ff6ee0c634dd0b0b4f92bdfe9667876b71c/websockets-17.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ce13c7d233239e739600a57d4a73c1192ad8259e655a4d55aa1a454242bc809d", size = 227664, upload-time = "2026-08-26T14:57:36.493Z" }, + { url = "https://files.pythonhosted.org/packages/ec/25/0dd4495df3c0e02f6db705312ba85ab9b2dd42257dc23eb0da10066e4844/websockets-17.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1036189bd34b0bc1b10a4679321e2c7968af317efe6e8e4c1c5141c4254fb5bb", size = 226447, upload-time = "2026-08-26T14:57:37.781Z" }, + { url = "https://files.pythonhosted.org/packages/be/67/6df3f63ffc48f08126ed0cd2fd2a41092967c3e364f8ec100deae90b6d77/websockets-17.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e78fd4b7b2c5086a38671c9c882c1e643385eccea360b5b1fda4a105e590087e", size = 225343, upload-time = "2026-08-26T14:57:39.133Z" }, + { url = "https://files.pythonhosted.org/packages/b1/8d/a8479bbb09ff054907d141123d8f52fb6ae5ac39c6dbe39e6a02a8408309/websockets-17.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:46e7a10bf04318c7b0c0273791925ae5e1cbe4a11e34aa934d2ef27862058a80", size = 222748, upload-time = "2026-08-26T14:57:40.478Z" }, + { url = "https://files.pythonhosted.org/packages/40/fb/4c3d2a3269cde3f3087916de9c3d9fc5d7196b46846d8c3a9ae59ad0a884/websockets-17.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:33e45c7ea38428e740a7f233555d71df0b875cef7fc080acebc9654475e35335", size = 225453, upload-time = "2026-08-26T14:57:41.859Z" }, + { url = "https://files.pythonhosted.org/packages/7f/1c/6467b401d19408f34e1c7389c222c2c7e1dfdf08c551190269b5eabc726c/websockets-17.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:6e63c01803be425ff062b7f7fc201a74def1d49fc94a2410dd17375df75936e9", size = 224112, upload-time = "2026-08-26T14:57:43.136Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5f/744e032ac80e11039a7447657ebabb46e9b5c2dbcec83be571335212932f/websockets-17.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:722ec21717eec6477bce582147a28acdfe034e604239466a6a95daedb863e774", size = 224646, upload-time = "2026-08-26T14:57:44.871Z" }, + { url = "https://files.pythonhosted.org/packages/9f/47/bcb9128d9afc4d0934d9192e2a24897ca2f7a63df2654904915349c6c46d/websockets-17.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:e74e41f0ad12ff1e8983e349daef79d37cc8280c743ce9d134d6c74c18dab5d6", size = 225797, upload-time = "2026-08-26T14:57:46.338Z" }, + { url = "https://files.pythonhosted.org/packages/c7/e0/b058047b7cf565e1105b10ef6b6b24a6ebe3575678c7dc75a645334705a7/websockets-17.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:12fe8984a32dbfd084e0603f1a8d740c0180cb85b3174585c54a80d2455a8394", size = 223605, upload-time = "2026-08-26T14:57:48.175Z" }, + { url = "https://files.pythonhosted.org/packages/b9/69/fc1555bff884de363f1bf9eebf2836dbeb29fa7e4f957debb7bbcf43abba/websockets-17.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:01dcb47deebc40b38fd4a493b9b9f4d0a704b7bec6f35e4d34085b329abce71a", size = 224508, upload-time = "2026-08-26T14:57:49.407Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f9/648d4e68621688b19093b06f7b497d520952e68cdea1c1b54371fe9491de/websockets-17.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f4c45ee2512d3757b5e6c67c5a34e435143f2ecb7df3324f9fd888688c45c0f4", size = 224767, upload-time = "2026-08-26T14:57:50.799Z" }, + { url = "https://files.pythonhosted.org/packages/58/93/f8342b55864f71df13eb8e9ef7dce691b87a87f04f75bb8a1385b3336e7c/websockets-17.1-cp315-cp315-win32.whl", hash = "sha256:0f4f50dfe2cc810fc4e2de979b35e83bf8bb4bccdc6fe472d93762ea7b1d5927", size = 217003, upload-time = "2026-08-26T14:57:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f0/7b5fdb774c245e0b6217009e2a24d2105c1a64923949f33be41aa7959302/websockets-17.1-cp315-cp315-win_amd64.whl", hash = "sha256:4af784f3e436f65b355c117c6497320f2b5cf6a559295cb1c4c7338e335d45cc", size = 217300, upload-time = "2026-08-26T14:57:53.492Z" }, + { url = "https://files.pythonhosted.org/packages/76/33/1fe6ed1b5087516115ca451b2c240314b010647071f8fc3bd78a21e4dddb/websockets-17.1-cp315-cp315-win_arm64.whl", hash = "sha256:d58159af7835fde09c462394293c0d7aaf8fb4557d8f8e5699f5e722ccae013d", size = 217214, upload-time = "2026-08-26T14:57:54.88Z" }, + { url = "https://files.pythonhosted.org/packages/94/ca/ed02e75996a266d76c5fcb5dd9b930db4cf2b388ca5fa3d2a72086f81568/websockets-17.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:1a5cf4e7bbe3ca499e6a289206cb4fcb7444b09919e129bd517f57d5fa192c13", size = 217282, upload-time = "2026-08-26T14:57:56.108Z" }, + { url = "https://files.pythonhosted.org/packages/bd/7d/d536f5bc89ea5b52fd1c1727c59fabafee6bc41f5ce92c3bd2f83047908c/websockets-17.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:416b4bc8789a1865a3ff643ec4ee073a5f52402d0dbeafd27b1798d5dd6b6a51", size = 214863, upload-time = "2026-08-26T14:57:57.355Z" }, + { url = "https://files.pythonhosted.org/packages/37/37/944cf17bad668e9be1247e6314f88a48b9faf7c250e383410db8b38af0b9/websockets-17.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:259f45358c76d3b18489e3e80636cdbe807e05ecf1b10fdf1a779106d23d0c8e", size = 215073, upload-time = "2026-08-26T14:57:58.719Z" }, + { url = "https://files.pythonhosted.org/packages/74/bf/3267966cc1bbc2b8fa62fd329651b0af502df1f5d1c0eed027ff339d6aa8/websockets-17.1-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d9d01e8ede41fea4f5a847dad9d628355f74905f437a5b6856d67aa66d193800", size = 225229, upload-time = "2026-08-26T14:58:00.235Z" }, + { url = "https://files.pythonhosted.org/packages/7f/d8/85ea722f483510abb39fc71aafb4465d17cf9051a275ab036874ff3c300c/websockets-17.1-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a7b35181a14cbfcae163b4de545d22abfd07d06c2c41ca69cfcd99251d6888ab", size = 225500, upload-time = "2026-08-26T14:58:01.994Z" }, + { url = "https://files.pythonhosted.org/packages/50/ce/64c7d00005bd0d15ecb5c5fcb7fb2597b6b92ddd16c4fa6bbc3d2835ad63/websockets-17.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6a8e768a048c2220697477ce2e67e4345dc9f693d0ee6af53945b5e30227c6a7", size = 226829, upload-time = "2026-08-26T14:58:03.327Z" }, + { url = "https://files.pythonhosted.org/packages/b4/dc/096c67940fb957e667ca3c542818150434eb0388c6fdc90b3a502f3c3e96/websockets-17.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:880069d21cc33a558dcf180924a546d1ecf8ada5be3e4e70acee87019d706a24", size = 228457, upload-time = "2026-08-26T14:58:04.78Z" }, + { url = "https://files.pythonhosted.org/packages/51/fe/f2331b6b7ccc67589891da354fa46a5cb79e95f83b9fd0e734d77f1f2140/websockets-17.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cec1bb8f22abccc8d20f8ca63df9be41600c26c190f4b97ee86c675fd4a863a6", size = 227265, upload-time = "2026-08-26T14:58:06.102Z" }, + { url = "https://files.pythonhosted.org/packages/47/a5/fb1642302f8ec77ca922203074f155a9831a5128ad75e725059a476d1227/websockets-17.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f3a1d577e081667dda7f8e5b4796e6e32f9713c93e2a3d930669519840a3c623", size = 226143, upload-time = "2026-08-26T14:58:07.464Z" }, + { url = "https://files.pythonhosted.org/packages/d7/41/7133fcfb63f5562750b269d6a845c689dde6a2c6407286da395beea19ddd/websockets-17.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dc053f9e95a76213c5eb7ed95779f7daf0d2bf0e4e03073629ebfa43a033f151", size = 223501, upload-time = "2026-08-26T14:58:08.766Z" }, + { url = "https://files.pythonhosted.org/packages/64/b1/82b36bfabc79ff2d383a1fc043cee6a13f794ef4f6bf1b4810ad6988cf6f/websockets-17.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:bb0efe019480a1c93e168ce96479273aaebd672fc8c350d5eed1e507ababb1b8", size = 226330, upload-time = "2026-08-26T14:58:09.987Z" }, + { url = "https://files.pythonhosted.org/packages/41/7d/5b511b9bf6e9ad331e6ff902fcbcc71c3794d10ef3b5efe80ccb8f0a7861/websockets-17.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:615746b12b26a3fd4077bc6fbeb277a1c192a45dd57b531d07ad9ed5c52a9a7a", size = 224980, upload-time = "2026-08-26T14:58:11.303Z" }, + { url = "https://files.pythonhosted.org/packages/e0/50/aed08f25301f8eef23be903ff9319fcf35630ca2bdec9d226f7d804dd5b3/websockets-17.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:1a20136d61f9ca3a31493732762661fafc2c20e8861930214e21afc6a8a692a2", size = 225478, upload-time = "2026-08-26T14:58:12.543Z" }, + { url = "https://files.pythonhosted.org/packages/3e/47/0d63d4168536b4682c9d19b7399443b1176f25dbb68878374fa716670230/websockets-17.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:2786cbd273ab69c22612db8a41229ddf2c158060b17b5928884bf388d07887f3", size = 226588, upload-time = "2026-08-26T14:58:14.457Z" }, + { url = "https://files.pythonhosted.org/packages/b3/dd/844bd0b6386fc81ed6a55f4b6dd26f01c6987eda205afa10175ea12b2164/websockets-17.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:b1c323fc3be1dc3f87f6c59458cb7d9e13dcbbf971d6c3f3e2bbaf58d3bfcdfe", size = 224336, upload-time = "2026-08-26T14:58:15.778Z" }, + { url = "https://files.pythonhosted.org/packages/96/18/03709c84bc88ec4dcea68d4be4ccd07d611073dec111203a5bf45af8809d/websockets-17.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:12c8e2b25df59755954a04dfa09c990b96691025aaf7eafd19ed6da24b09c18d", size = 225197, upload-time = "2026-08-26T14:58:17.141Z" }, + { url = "https://files.pythonhosted.org/packages/27/cf/0d1c694b6466c89e875b85b32b51312c472cf6708eee91914866f5087dde/websockets-17.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f58f58b4b29bbea2a3635e2c56eff4a3adab011fe383802a9e542e31b97085fc", size = 225493, upload-time = "2026-08-26T14:58:18.521Z" }, + { url = "https://files.pythonhosted.org/packages/4e/f5/99857c3dd9676749f33e3668665a34ad6099505fb8d75eb084f49f7807a9/websockets-17.1-cp315-cp315t-win32.whl", hash = "sha256:f78a3ffb1994304db2c0c4588e4d1a518079b557054fa3bb985a6f5e50ff49a3", size = 217130, upload-time = "2026-08-26T14:58:20.037Z" }, + { url = "https://files.pythonhosted.org/packages/2c/84/77599922ab441bfe61508f97dab2c71f8e114d31793993ea54011db16199/websockets-17.1-cp315-cp315t-win_amd64.whl", hash = "sha256:ad68c28a27246fed109a4409393d677b7e1388345cbbd2f5aee5c182d8506110", size = 217448, upload-time = "2026-08-26T14:58:21.382Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3c/8b9a225b523f06a9389be81f1b0ab07c49bec6014742e6aa359c1f920f1f/websockets-17.1-cp315-cp315t-win_arm64.whl", hash = "sha256:e552e0037230ac16e5f568de7012041344d1b18c9feed30ec2891b8eba55af81", size = 217372, upload-time = "2026-08-26T14:58:22.807Z" }, + { url = "https://files.pythonhosted.org/packages/41/63/23572870e01836a98346075b9e17a8bc24a6ddd9800a3204ceee58677f3c/websockets-17.1-py3-none-any.whl", hash = "sha256:f221081107b8c48184d99f7019604486376e7ef826037e70aad6b02540732c23", size = 211134, upload-time = "2026-08-26T17:25:31.397Z" }, +]