diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..52240b1e8 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +node_modules +dist/ +.env +__pycache__/ +.venv/ +*.pyc diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..4a7ea3036 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.md] +trim_trailing_whitespace = false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..9e05d3a56 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,70 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +jobs: + frontend: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 9 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + - run: pnpm install --frozen-lockfile + - name: Lint + run: pnpm lint + - name: Build (tsc type-check + vite build — the merge gate) + run: pnpm build + - name: Security audit + run: pnpm audit --audit-level=high + + api: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: user + POSTGRES_PASSWORD: pass + POSTGRES_DB: venue404 + ports: + - 5432:5432 + options: >- + --health-cmd="pg_isready -U user -d venue404" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + env: + DATABASE_URL: postgresql://user:pass@localhost:5432/venue404 + SUPABASE_URL: https://example.supabase.co + SUPABASE_JWT_SECRET: test-secret + SUPABASE_SERVICE_ROLE_KEY: test-service-role + STRIPE_SECRET_KEY: sk_test_dummy + STRIPE_WEBHOOK_SECRET: whsec_dummy + defaults: + run: + working-directory: apps/api + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v4 + with: + enable-cache: true + cache-dependency-glob: "apps/api/pyproject.toml" + python-version: "3.12" + - name: Install dependencies + run: uv pip install -e ".[dev]" + - name: Lint + run: ruff check . + - name: Migrate + run: alembic upgrade head + - name: Tests + run: pytest -q + - name: Security audit + run: uvx pip-audit diff --git a/.github/workflows/jobs.yml b/.github/workflows/jobs.yml new file mode 100644 index 000000000..f10e237c7 --- /dev/null +++ b/.github/workflows/jobs.yml @@ -0,0 +1,45 @@ +name: Background Jobs + +# Triggers the API's background jobs at their original cadences by calling the +# token-guarded POST /api/internal/run-jobs endpoint. The in-process scheduler +# stays off (ENABLE_JOBS=false) so the free Render service can sleep. +# +# hourly (0 * * * *) -> hold_expiry, payment_reminders +# 6-hourly (0 */6 * * *) -> request_expiry, overdue_flag, overdue_autocancel +# daily (0 0 * * *) -> completion +on: + schedule: + - cron: "0 * * * *" + - cron: "0 */6 * * *" + - cron: "0 0 * * *" + workflow_dispatch: + inputs: + jobs: + description: "Comma-separated job names to run" + required: true + default: "hold_expiry,payment_reminders,request_expiry,overdue_flag,overdue_autocancel,completion,search_indexer" + +jobs: + run: + runs-on: ubuntu-latest + steps: + - name: Select job set + id: select + run: | + case "${{ github.event.schedule }}" in + "0 * * * *") JOBS="hold_expiry,payment_reminders,search_indexer" ;; + "0 */6 * * *") JOBS="request_expiry,overdue_flag,overdue_autocancel" ;; + "0 0 * * *") JOBS="completion" ;; + *) JOBS="${{ github.event.inputs.jobs }}" ;; + esac + echo "jobs=$JOBS" >> "$GITHUB_OUTPUT" + echo "Running jobs: $JOBS" + + - name: Warm up (ride out free-tier cold start) + run: curl -fsS --max-time 90 --retry 5 --retry-all-errors --retry-delay 12 "${{ secrets.API_BASE_URL }}/health" + + - name: Trigger jobs + run: | + curl -fsS --max-time 300 -X POST \ + -H "X-Job-Token: ${{ secrets.JOB_RUNNER_TOKEN }}" \ + "${{ secrets.API_BASE_URL }}/api/internal/run-jobs?jobs=${{ steps.select.outputs.jobs }}" diff --git a/.github/workflows/keepalive.yml b/.github/workflows/keepalive.yml new file mode 100644 index 000000000..2ce49d0ba --- /dev/null +++ b/.github/workflows/keepalive.yml @@ -0,0 +1,19 @@ +name: Keepalive + +# Pings the API /health every 13 minutes so Render's free web service does not +# spin down (it sleeps after ~15 min idle). GitHub Actions cron is best-effort. +# +# NOTE: on a PRIVATE repo this consumes ~3,300 Actions minutes/month and will +# exceed the 2,000-min free budget — use a free external pinger (UptimeRobot / +# cron-job.org) instead. On a PUBLIC repo, Actions minutes are free. +on: + schedule: + - cron: "*/13 * * * *" + workflow_dispatch: + +jobs: + ping: + runs-on: ubuntu-latest + steps: + - name: Ping /health + run: curl -fsS --max-time 60 --retry 3 --retry-delay 10 "${{ secrets.API_BASE_URL }}/health" diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..3bf5e0f4b --- /dev/null +++ b/.gitignore @@ -0,0 +1,44 @@ +# ---- Dependencies ---- +node_modules/ +.venv/ +*.egg-info/ + +# ---- Build output ---- +dist/ +build/ +*.tsbuildinfo + +# ---- Environment secrets ---- +.env +.env.local +.env.*.local + +# ---- Python runtime ---- +__pycache__/ +*.py[cod] +*.pyo + +# ---- Test & coverage ---- +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +htmlcov/ +.coverage +coverage.xml + +# ---- Editor & OS ---- +.vscode/ +.idea/ +*.swp +.DS_Store +Thumbs.db + +# ---- Misc ---- +prd.txt +auth-prd.md +.claude/ +claude.md +/prds + +.akhil +self/ \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 000000000..238d4d936 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,7 @@ +{ + "semi": false, + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "es5", + "printWidth": 100 +} diff --git a/apps/admin-panel/.env.example b/apps/admin-panel/.env.example new file mode 100644 index 000000000..f940594e8 --- /dev/null +++ b/apps/admin-panel/.env.example @@ -0,0 +1,7 @@ +# Supabase (browser client - anon key only) +VITE_SUPABASE_URL=https://your-project-ref.supabase.co +VITE_SUPABASE_ANON_KEY=your-supabase-anon-key + +# FastAPI backend +VITE_API_BASE_URL=http://localhost:8000 +VITE_STRIPE_PUBLISHABLE_KEY= \ No newline at end of file diff --git a/apps/admin-panel/index.html b/apps/admin-panel/index.html new file mode 100644 index 000000000..af41e4f9b --- /dev/null +++ b/apps/admin-panel/index.html @@ -0,0 +1,16 @@ + + + +
+ + + +| Amenity | +Venues using it | +Status | +Created | +Actions | +
|---|---|---|---|---|
|
+
+ {amenity.icon ? (
+
+ {amenity.icon}
+
+ ) : (
+
+
+
+ )}
+ {amenity.name}
+
+ |
+ + {amenity.active_venue_count > 0 ? ( + + {amenity.active_venue_count} {amenity.active_venue_count === 1 ? 'venue' : 'venues'} + + ) : ( + None + )} + | +
+ {amenity.deleted_at ? (
+ |
+ + {new Date(amenity.created_at).toLocaleDateString('en-IN', { + year: 'numeric', month: 'short', day: 'numeric', + })} + | +
+ {amenity.deleted_at ? null : (
+
+
+
+
+ )}
+ |
+
+ This amenity will immediately be available for venue owners to add to their listings. +
+ ++ {createMutation.error instanceof Error ? createMutation.error.message : 'Failed to create amenity'} +
+ )} + ++ Changes apply immediately across all venues using this amenity. +
+ ++ {editMutation.error instanceof Error ? editMutation.error.message : 'Failed to update amenity'} +
+ )} + ++ {deleteTarget?.name} will be hidden from venue owners and can no longer be assigned to new listings. +
+ + {/* Active venue warning */} + {deleteTarget && deleteTarget.active_venue_count > 0 && ( ++ {deleteTarget.active_venue_count} {deleteTarget.active_venue_count === 1 ? 'venue is' : 'venues are'} currently using this amenity.{' '} + Their existing listings will be unaffected, but this amenity won't appear in the picker for new selections. +
+| Action | +Target | +Admin | +Reason | +Time | +
|---|
| Venue | +Customer | +Event date | +Booking status | +Payment | +Guests | +Requested | ++ |
|---|
{role}
+{name ?? '—'}
+{b.id}
+Venue
+Event date
+{fmtDate(b.event_date)}
+Guests
+{b.guest_count}
+Booking status
+Payment
++ Requested {fmtDateTime(b.created_at)} +
+ +| Category | +Slug | +Banner | +Venues | +Status | +Order | +Actions | +
|---|---|---|---|---|---|---|
|
+
+ {cat.icon && (
+ {cat.icon}
+ )}
+ {cat.label}
+
+ |
+ {cat.slug} | +
+ {cat.banner_image ? (
+
+
+ ) : (
+ !cat.deleted_at ? (
+
+ ) : (
+ None
+ )
+ )}
+ |
+ + {cat.venue_count > 0 ? ( + + {cat.venue_count} {cat.venue_count === 1 ? 'venue' : 'venues'} + + ) : ( + None + )} + | +
+ {cat.deleted_at ? (
+ |
+ {cat.sort_order} | +
+ {!cat.deleted_at && (
+
+
+
+
+ )}
+ |
+
Slug is permanent and used in URLs (lowercase, underscores only).
++ {createMutation.error instanceof Error ? createMutation.error.message : 'Failed to create category'} +
+ )} +Slug cannot be changed after creation.
++ {editMutation.error instanceof Error ? editMutation.error.message : 'Failed to update category'} +
+ )} ++ {deleteTarget?.label} will be hidden from venue owners. +
+ {deleteTarget && deleteTarget.venue_count > 0 && ( ++ {deleteTarget.venue_count} {deleteTarget.venue_count === 1 ? 'venue uses' : 'venues use'} this category.{' '} + Existing venues are unaffected but this category won't appear for new venues. +
++ Here is what needs your attention today. +
+| Query | +City | +Results | +Match score | +When | ++ |
|---|
{label}
+{value ?? '—'}
+{fmtDateTime(detail.created_at)}
} +Loading…
} + + {detail && ( + <> +Raw query
+{detail.query_text}
+Model breakdown
+Results found
+{detail.result_count}
+Avg match score
+{pct(detail.avg_match_score)}
+Top results
+All admin sessions are logged and audited.
+| User | +Roles | +Status | +Joined | +Actions | +
|---|---|---|---|---|
|
+
+
+ {initials(user)}
+
+
+
+
+ {user.full_name ?? '—'}
+ {user.email ?? '—'}
+ |
+
+
+ {user.roles.map((r) => (
+
+ |
+
+ |
+ + {new Date(user.created_at).toLocaleDateString('en-IN', { + year: 'numeric', month: 'short', day: 'numeric', + })} + | +
+ {user.is_super_admin ? (
+
+
+
+
+ ) : user.status === 'active' ? (
+
+ ) : user.status === 'suspended' || user.status === 'rejected' ? (
+
+ ) : null}
+ |
+
+ + {suspendTarget?.full_name ?? suspendTarget?.email ?? 'This user'} + {' '} + will immediately lose platform access and cannot log in until reactivated. +
+ ++ {suspendMutation.error instanceof Error ? suspendMutation.error.message : 'Failed to suspend user'} +
+ )} ++ + {reactivateTarget?.full_name ?? reactivateTarget?.email ?? 'This user'} + {' '} + will immediately regain full platform access. +
+ {reactivateMutation.error && ( ++ + {approveTarget?.full_name ?? approveTarget?.email ?? 'This user'} + {' '} + will be granted full venue owner access immediately. +
+ {approveMutation.error && ( ++ + {rejectTarget?.full_name ?? rejectTarget?.email ?? 'This user'} + {' '} + will be notified their application was not approved. They can re-apply later. +
++ {rejectMutation.error instanceof Error ? rejectMutation.error.message : 'Failed to reject owner'} +
+ )} ++ {approveTarget?.name} will be + listed publicly and visible to customers immediately. +
+ {approveMutation.error && ( ++ {rejectTarget?.name} will be + marked rejected. The owner can resubmit after making changes. +
++ {rejectMutation.error instanceof Error ? rejectMutation.error.message : 'Failed to reject venue'} +
+ )} ++ {suspendTarget?.name} will be + hidden from customers immediately. This action cannot proceed if the venue has + active bookings. +
++ Venues with confirmed or accepted bookings cannot be suspended. +
++ {suspendMutation.error instanceof Error ? suspendMutation.error.message : 'Failed to suspend venue'} +
+ )} ++ {reactivateTarget?.name} will be + restored to approved status and visible to customers again. +
+ {reactivateMutation.error && ( +
+
{venue.owner.full_name ?? '—'}
+{venue.owner.email ?? '—'}
+{venue.description}
+ )} + + {/* Stats row */} +{value}
+Advance Required
+{venue.advance_pct}%
+Platform Commission
+{venue.platform_commission_pct}%
+Amenities
++ Submitted {new Date(venue.created_at).toLocaleDateString('en-IN', { year: 'numeric', month: 'long', day: 'numeric' })} +
+ +
+
{venue.owner.full_name ?? '—'}
+{venue.owner.email ?? '—'}
+| Owner | +Phone | +Status | +Applied | +Actions | +
|---|---|---|---|---|
|
+
+
+ {initials(user)}
+
+
+
+
+ {user.full_name ?? '—'}
+ {user.email ?? '—'}
+ |
+
+ {/* Phone */}
+ + {user.phone ? ( + + + {user.phone} + + ) : ( + — + )} + | + + {/* Status */} +
+ |
+
+ {/* Applied date */}
+ + {new Date(user.created_at).toLocaleDateString('en-IN', { + year: 'numeric', month: 'short', day: 'numeric', + })} + | + + {/* Actions */} +
+ {user.is_super_admin ? (
+
+
+
+
+ ) : user.status === 'active' ? (
+
+ ) : user.status === 'suspended' || user.status === 'rejected' ? (
+
+ ) : null}
+ |
+
+ + {approveTarget?.full_name ?? approveTarget?.email ?? 'This applicant'} + {' '} + will be granted full venue owner access and can start listing venues immediately. +
+ {approveError && ( ++ + {rejectTarget?.full_name ?? rejectTarget?.email ?? 'This applicant'} + {' '} + will be notified their application was not approved. They can re-apply after reviewing the feedback. +
+{rejectError}
+ )} ++ + {suspendTarget?.full_name ?? suspendTarget?.email ?? 'This owner'} + {' '} + will immediately lose platform access. Their active venues will be hidden until the account is reactivated. +
+{suspendError}
+ )} ++ + {reactivateTarget?.full_name ?? reactivateTarget?.email ?? 'This owner'} + {' '} + will regain full venue owner access immediately. +
+ {reactivateError && ( +{body}
{link}" + return title, body, html diff --git a/apps/api/app/modules/notification/templates/.gitkeep b/apps/api/app/modules/notification/templates/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/apps/api/app/modules/notification/types.py b/apps/api/app/modules/notification/types.py new file mode 100644 index 000000000..8ff432e3b --- /dev/null +++ b/apps/api/app/modules/notification/types.py @@ -0,0 +1,25 @@ +"""Canonical notification type identifiers. + +Producers (booking, payment, jobs) should reference these constants instead of +raw strings so a type can never drift from a template key. Both British and +American spellings resolve to the same template (see ALIASES in templates.py), +so callers are free to use either spelling. +""" + + +class NotificationType: + REQUEST_RECEIVED = "request_received" + NEW_REQUEST_OWNER = "new_request_owner" + REQUEST_ACCEPTED = "request_accepted" + BOOKING_REJECTED = "booking_rejected" + PAYMENT_REMINDER = "payment_reminder" + PAYMENT_CONFIRMED = "payment_confirmed" + BALANCE_PAID = "balance_paid" + BALANCE_OVERDUE = "balance_overdue" + BALANCE_DEADLINE_EXTENDED = "balance_deadline_extended" + HOLD_EXPIRED = "hold_expired" + CONFLICT_CANCELED = "conflict_canceled" + BOOKING_CANCELED = "booking_canceled" + REQUEST_EXPIRED = "request_expired" + REFUND_ISSUED = "refund_issued" + BOOKING_COMPLETED = "booking_completed" diff --git a/apps/api/app/modules/owner/__init__.py b/apps/api/app/modules/owner/__init__.py new file mode 100644 index 000000000..647a7d3f1 --- /dev/null +++ b/apps/api/app/modules/owner/__init__.py @@ -0,0 +1 @@ +# Owner module diff --git a/apps/api/app/modules/owner/routes.py b/apps/api/app/modules/owner/routes.py new file mode 100644 index 000000000..c09f0931e --- /dev/null +++ b/apps/api/app/modules/owner/routes.py @@ -0,0 +1,37 @@ +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from app.core.database import get_db +from app.modules.auth.dependencies import AuthContext, require_owner +from app.modules.owner import service +from app.modules.owner.schemas import DashboardStats, ChartDataPoint, UpcomingEventOut + +router = APIRouter() + + +@router.get("/dashboard/stats", response_model=DashboardStats) +def get_owner_dashboard_stats( + auth: AuthContext = Depends(require_owner), + db: Session = Depends(get_db), +): + """Aggregated KPI and financial stats for the owner dashboard.""" + return service.get_dashboard_stats(db, auth.user_id) + + +@router.get("/dashboard/chart", response_model=list[ChartDataPoint]) +def get_owner_dashboard_chart( + time_range: str = "6M", + auth: AuthContext = Depends(require_owner), + db: Session = Depends(get_db), +): + """Monthly performance chart data for the owner dashboard.""" + return service.get_dashboard_chart(db, auth.user_id, time_range) + + +@router.get("/dashboard/upcoming-events", response_model=list[UpcomingEventOut]) +def get_owner_dashboard_upcoming_events( + auth: AuthContext = Depends(require_owner), + db: Session = Depends(get_db), +): + """Upcoming confirmed events for the owner dashboard.""" + return service.get_upcoming_events(db, auth.user_id) diff --git a/apps/api/app/modules/owner/schemas.py b/apps/api/app/modules/owner/schemas.py new file mode 100644 index 000000000..527f1280f --- /dev/null +++ b/apps/api/app/modules/owner/schemas.py @@ -0,0 +1,37 @@ +from datetime import datetime +from typing import Optional +from pydantic import BaseModel + + +class DashboardStats(BaseModel): + active_venues: int + pending_requests: int + active_bookings: int + completed_bookings: int + cancelled_bookings: int + # Financial (paise) + gross_volume_paise: int + net_revenue_paise: int + available_balance_paise: int + platform_fees_paise: int + refunds_issued_paise: int + payouts_completed_paise: int + + +class ChartDataPoint(BaseModel): + month: str # e.g. "Jan 26" + enquiries: int + completed: int + cancelled: int + + +class UpcomingEventOut(BaseModel): + booking_id: str + event_type: Optional[str] = None + venue_name: str + status: str + starts_at: Optional[str] = None # ISO string + guest_count: int + + + diff --git a/apps/api/app/modules/owner/service.py b/apps/api/app/modules/owner/service.py new file mode 100644 index 000000000..73884ff1d --- /dev/null +++ b/apps/api/app/modules/owner/service.py @@ -0,0 +1,252 @@ +""" +Owner dashboard service. + +Returns a single aggregated response containing: + - KPI counts (pending requests, active bookings, completed, cancelled) + - Financial stats (reuses ledger logic from payment service) + - Monthly performance chart data (last 6 months) + - Upcoming confirmed events (next 5) +""" +from datetime import datetime, timezone, timedelta +from uuid import UUID +from calendar import month_abbr + +from sqlalchemy.orm import Session +from sqlalchemy import func, case + +from app.modules.booking.models import Booking, BookingStatus, BookingSlot +from app.modules.venue.models import Venue, VenueStatus +from app.modules.payment.models import LedgerEntry +from app.modules.owner.schemas import ( + DashboardStats, + ChartDataPoint, + UpcomingEventOut, +) + +# Statuses considered "cancelled/terminal non-success" +CANCELLED_STATUSES = [ + BookingStatus.conflict_cancelled, + BookingStatus.user_cancelled, + BookingStatus.admin_cancelled, + BookingStatus.owner_rejected, + BookingStatus.balance_overdue_cancelled, + BookingStatus.hold_expired, + BookingStatus.request_expired, +] + + +def get_dashboard_stats(db: Session, owner_id: UUID) -> DashboardStats: + # ------------------------------------------------------------------ # + # 1. Booking counts — one query, grouped by status + # ------------------------------------------------------------------ # + active_venues = ( + db.query(func.count(Venue.id)) + .filter( + Venue.owner_id == owner_id, + Venue.is_active == True, + Venue.status == VenueStatus.approved, + Venue.deleted_at.is_(None) + ) + .scalar() or 0 + ) + + status_counts: dict[str, int] = {} + + rows = ( + db.query(Booking.status, func.count(Booking.id).label("cnt")) + .join(Venue, Booking.venue_id == Venue.id) + .filter( + Venue.owner_id == owner_id, + Booking.deleted_at.is_(None), + ) + .group_by(Booking.status) + .all() + ) + for status_val, cnt in rows: + status_counts[status_val] = cnt + + pending_requests = status_counts.get(BookingStatus.requested, 0) + active_bookings = status_counts.get(BookingStatus.confirmed, 0) + completed_bookings = status_counts.get(BookingStatus.completed, 0) + cancelled_bookings = sum( + status_counts.get(s, 0) for s in CANCELLED_STATUSES + ) + + # ------------------------------------------------------------------ # + # 2. Financial stats — from ledger entries + # ------------------------------------------------------------------ # + ledger_rows = ( + db.query( + LedgerEntry.entry_type, + LedgerEntry.direction, + func.sum(LedgerEntry.amount_paise).label("total"), + ) + .filter(LedgerEntry.owner_id == owner_id) + .group_by(LedgerEntry.entry_type, LedgerEntry.direction) + .all() + ) + + gross_volume = 0 + platform_fees = 0 + refunds_issued = 0 + payouts_completed = 0 + + for entry_type, direction, total in ledger_rows: + val = int(total or 0) + if entry_type == "charge" and direction == "credit": + gross_volume += val + elif entry_type == "platform_fee" and direction == "debit": + platform_fees += val + elif entry_type == "refund" and direction == "debit": + refunds_issued += val + elif entry_type == "payout" and direction == "debit": + payouts_completed += val + + net_revenue = gross_volume - platform_fees - refunds_issued + available_balance = net_revenue - payouts_completed + + stats = DashboardStats( + active_venues=active_venues, + pending_requests=pending_requests, + active_bookings=active_bookings, + completed_bookings=completed_bookings, + cancelled_bookings=cancelled_bookings, + gross_volume_paise=gross_volume, + net_revenue_paise=net_revenue, + available_balance_paise=available_balance, + platform_fees_paise=platform_fees, + refunds_issued_paise=refunds_issued, + payouts_completed_paise=payouts_completed, + ) + + return stats + + +def get_dashboard_chart(db: Session, owner_id: UUID, time_range: str = "6M") -> list[ChartDataPoint]: + # ------------------------------------------------------------------ # + # Chart data — dynamic time range + # ------------------------------------------------------------------ # + now = datetime.now(timezone.utc) + is_daily = time_range in ("7D", "30D") + + num_buckets = 6 + if time_range == "7D": num_buckets = 7 + elif time_range == "30D": num_buckets = 30 + elif time_range == "3M": num_buckets = 3 + elif time_range == "12M": num_buckets = 12 + + buckets: list[tuple[int, int, int]] = [] + + if is_daily: + for delta in range(num_buckets - 1, -1, -1): + t = now - timedelta(days=delta) + buckets.append((t.year, t.month, t.day)) + start_of_window = datetime(buckets[0][0], buckets[0][1], buckets[0][2], tzinfo=timezone.utc) + else: + for delta in range(num_buckets - 1, -1, -1): + y = now.year + m = now.month - delta + while m < 1: + m += 12 + y -= 1 + buckets.append((y, m, 1)) + start_of_window = datetime(buckets[0][0], buckets[0][1], 1, tzinfo=timezone.utc) + + if is_daily: + group_fields = [ + func.extract("year", Booking.created_at).label("yr"), + func.extract("month", Booking.created_at).label("mo"), + func.extract("day", Booking.created_at).label("day"), + ] + else: + group_fields = [ + func.extract("year", Booking.created_at).label("yr"), + func.extract("month", Booking.created_at).label("mo"), + ] + + chart_bookings = ( + db.query(Booking.status, func.count(Booking.id).label("cnt"), *group_fields) + .join(Venue, Booking.venue_id == Venue.id) + .filter( + Venue.owner_id == owner_id, + Booking.deleted_at.is_(None), + Booking.created_at >= start_of_window, + ) + .group_by(Booking.status, *group_fields) + .all() + ) + + bucket_map: dict[tuple[int, int, int], dict[str, int]] = { + b: {"enquiries": 0, "completed": 0, "cancelled": 0} + for b in buckets + } + + for row in chart_bookings: + status_val = row.status + cnt = row.cnt + yr = int(row.yr) + mo = int(row.mo) + day = int(row.day) if is_daily else 1 + + key = (yr, mo, day) + if key not in bucket_map: + continue + + # Every booking created is considered an enquiry, regardless of its final status + bucket_map[key]["enquiries"] += cnt + + if status_val == BookingStatus.completed: + bucket_map[key]["completed"] += cnt + elif status_val in CANCELLED_STATUSES: + bucket_map[key]["cancelled"] += cnt + + chart_data = [] + for b in buckets: + dt = datetime(b[0], b[1], b[2]) + month_label = dt.strftime("%d %b") if is_daily else dt.strftime("%b %y") + + chart_data.append( + ChartDataPoint( + month=month_label, + enquiries=bucket_map[b]["enquiries"], + completed=bucket_map[b]["completed"], + cancelled=bucket_map[b]["cancelled"], + ) + ) + + return chart_data + + +def get_upcoming_events(db: Session, owner_id: UUID) -> list[UpcomingEventOut]: + # ------------------------------------------------------------------ # + # Upcoming confirmed events — next 5 + # ------------------------------------------------------------------ # + now = datetime.now(timezone.utc) + upcoming_rows = ( + db.query(Booking, BookingSlot, Venue) + .join(BookingSlot, BookingSlot.booking_id == Booking.id) + .join(Venue, Booking.venue_id == Venue.id) + .filter( + Venue.owner_id == owner_id, + Booking.deleted_at.is_(None), + Booking.status == BookingStatus.confirmed, + BookingSlot.starts_at > now, + ) + .order_by(BookingSlot.starts_at.asc()) + .limit(5) + .all() + ) + + upcoming_events = [ + UpcomingEventOut( + booking_id=str(booking.id), + event_type=booking.event_type, + venue_name=venue.name, + status=booking.status, + starts_at=slot.starts_at.isoformat() if slot.starts_at else None, + guest_count=booking.guest_count, + ) + for booking, slot, venue in upcoming_rows + ] + + return upcoming_events diff --git a/apps/api/app/modules/payment/models.py b/apps/api/app/modules/payment/models.py new file mode 100644 index 000000000..25883b63d --- /dev/null +++ b/apps/api/app/modules/payment/models.py @@ -0,0 +1,137 @@ +import enum +import uuid +from datetime import datetime +from sqlalchemy import String, BigInteger, Text, Enum, ForeignKey, DateTime, func +from sqlalchemy.dialects.postgresql import UUID, JSONB +from sqlalchemy.orm import mapped_column, Mapped +from app.core.database import Base +from app.shared.models import TimestampMixin + + +class PaymentAttemptStatus(str, enum.Enum): + pending = "pending" + succeeded = "succeeded" + failed = "failed" + refunded = "refunded" + + +class RefundStatus(str, enum.Enum): + pending = "pending" + succeeded = "succeeded" + failed = "failed" + + +class PayoutStatus(str, enum.Enum): + requested = "requested" + processing = "processing" + paid = "paid" + failed = "failed" + + +class Payment(Base, TimestampMixin): + """Read-model of a single Stripe charge attempt for a booking.""" + __tablename__ = "payments" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + booking_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("bookings.id", ondelete="RESTRICT"), nullable=False + ) + amount_paise: Mapped[int] = mapped_column(BigInteger, nullable=False) + currency: Mapped[str] = mapped_column(String, nullable=False, default="inr") + status: Mapped[PaymentAttemptStatus] = mapped_column( + Enum(PaymentAttemptStatus, name="payment_attempt_status", create_constraint=False), + nullable=False, + default=PaymentAttemptStatus.pending, + ) + stripe_payment_intent_id: Mapped[str] = mapped_column(String, nullable=False) + stripe_client_secret: Mapped[str | None] = mapped_column(String, nullable=True) + # "advance" | "balance" — lets the webhook route a capture to the right + # confirmation path (advance confirms the booking; balance settles it). + # server_default keeps autogenerated NOT NULL adds safe on a populated table. + payment_type: Mapped[str] = mapped_column( + String, nullable=False, default="advance", server_default="advance" + ) + + +class Refund(Base, TimestampMixin): + __tablename__ = "refunds" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + payment_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("payments.id", ondelete="RESTRICT"), nullable=False + ) + booking_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("bookings.id", ondelete="RESTRICT"), nullable=False + ) + amount_paise: Mapped[int] = mapped_column(BigInteger, nullable=False) + reason: Mapped[str | None] = mapped_column(Text, nullable=True) + status: Mapped[RefundStatus] = mapped_column( + Enum(RefundStatus, name="refund_status", create_constraint=False), + nullable=False, + default=RefundStatus.pending, + ) + stripe_refund_id: Mapped[str | None] = mapped_column(String, nullable=True) + + +class LedgerEntry(Base): + """Append-only record of every money movement — the single source of truth. + `payments` / `refunds` are convenience read-models over these events. + """ + __tablename__ = "ledger_entries" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + booking_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("bookings.id", ondelete="RESTRICT"), nullable=False + ) + venue_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("venues.id", ondelete="RESTRICT"), nullable=False + ) + owner_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("profiles.id", ondelete="RESTRICT"), nullable=False + ) + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("profiles.id", ondelete="RESTRICT"), nullable=False + ) + entry_type: Mapped[str] = mapped_column(String, nullable=False) # charge|refund|payout|platform_fee + amount_paise: Mapped[int] = mapped_column(BigInteger, nullable=False) + direction: Mapped[str] = mapped_column(String, nullable=False) # credit|debit + stripe_pi_ref: Mapped[str | None] = mapped_column(String, nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=func.now() + ) + + +class PayoutRequest(Base, TimestampMixin): + __tablename__ = "payout_requests" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + owner_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("profiles.id", ondelete="RESTRICT"), nullable=False + ) + booking_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("bookings.id", ondelete="RESTRICT"), nullable=False + ) + amount_paise: Mapped[int] = mapped_column(BigInteger, nullable=False) + status: Mapped[PayoutStatus] = mapped_column( + Enum(PayoutStatus, name="payout_status", create_constraint=False), + nullable=False, + default=PayoutStatus.requested, + ) + stripe_transfer_id: Mapped[str | None] = mapped_column(String, nullable=True) + processed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + +class StripeEvent(Base): + """Webhook idempotency guard. PK is the Stripe event id, so a duplicate + insert (replayed webhook) raises IntegrityError and the handler no-ops. + """ + __tablename__ = "stripe_events" + + id: Mapped[str] = mapped_column(String, primary_key=True) # Stripe event id + type: Mapped[str] = mapped_column(String, nullable=False) + processed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + processing_error: Mapped[str | None] = mapped_column(Text, nullable=True) + raw_payload: Mapped[dict | None] = mapped_column(JSONB, nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=func.now() + ) diff --git a/apps/api/app/modules/payment/routes.py b/apps/api/app/modules/payment/routes.py new file mode 100644 index 000000000..aef8a0a13 --- /dev/null +++ b/apps/api/app/modules/payment/routes.py @@ -0,0 +1,65 @@ +from fastapi import APIRouter, Depends, Request +from sqlalchemy.orm import Session + +from app.core.database import get_db +from app.modules.auth.dependencies import get_current_user, AuthContext +from app.modules.payment.schemas import ( + CreatePaymentRequest, PaymentIntentResponse, PaymentResponse, + RefundRequest, RefundResponse, OwnerLedgerStatsResponse, LedgerEntryResponse +) +from typing import Optional +from app.modules.payment import service, webhooks + +router = APIRouter() + + +@router.get("/owner/stats", response_model=OwnerLedgerStatsResponse) +def get_owner_stats( + user: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), +): + return service.get_owner_ledger_stats(db, user) + + +@router.get("/owner/ledger", response_model=list[LedgerEntryResponse]) +def get_owner_ledger( + entry_type: Optional[str] = None, + user: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), +): + return service.list_owner_ledger_entries(db, user, entry_type) + + +@router.post("/", response_model=PaymentIntentResponse, status_code=201) +def create_payment( + body: CreatePaymentRequest, + user: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Create a Stripe PaymentIntent for a booking's token advance or balance.""" + return service.create_payment_intent(db, user.user_id, body.booking_id, body.payment_type) + + +@router.post("/webhook") +async def stripe_webhook(request: Request): + """Stripe calls this — no auth dependency; verified by signature instead.""" + return await webhooks.handle(request) + + +@router.post("/refund", response_model=RefundResponse) +def refund( + body: RefundRequest, + user: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Owner/admin full refund of a booking's captured payment.""" + return service.refund_booking(db, body.booking_id, user, body.reason) + + +@router.get("/{booking_id}", response_model=list[PaymentResponse]) +def get_payments( + booking_id: str, + user: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), +): + return service.list_payments_for_booking(db, booking_id, user) diff --git a/apps/api/app/modules/payment/schemas.py b/apps/api/app/modules/payment/schemas.py new file mode 100644 index 000000000..14b2c7228 --- /dev/null +++ b/apps/api/app/modules/payment/schemas.py @@ -0,0 +1,61 @@ +from typing import Literal + +from pydantic import BaseModel + + +class CreatePaymentRequest(BaseModel): + booking_id: str + # "advance" (token, confirms the booking) or "balance" (settles a confirmed + # booking). Amount is computed server-side from the venue pricing snapshot — + # never trusted from the client. + payment_type: Literal["advance", "balance", "full"] = "advance" + + +class PaymentIntentResponse(BaseModel): + payment_id: str + booking_id: str + client_secret: str | None + amount_paise: int + currency: str + status: str + + +class PaymentResponse(BaseModel): + id: str + booking_id: str + amount_paise: int + currency: str + status: str + stripe_payment_intent_id: str + + +class RefundRequest(BaseModel): + booking_id: str + reason: str | None = None + + +class RefundResponse(BaseModel): + booking_id: str + refunded_paise: int + status: str + + +class OwnerLedgerStatsResponse(BaseModel): + gross_volume_paise: int + platform_fees_paise: int + refunds_issued_paise: int + net_revenue_paise: int + payouts_completed_paise: int + available_balance_paise: int + +class LedgerEntryResponse(BaseModel): + id: str + booking_id: str + venue_id: str + venue_name: str | None = None + user_full_name: str | None = None + entry_type: str + amount_paise: int + direction: str + stripe_pi_ref: str | None = None + created_at: str diff --git a/apps/api/app/modules/payment/service.py b/apps/api/app/modules/payment/service.py new file mode 100644 index 000000000..b3ba3b223 --- /dev/null +++ b/apps/api/app/modules/payment/service.py @@ -0,0 +1,627 @@ +"""Payment service — Stripe charge/refund logic with race-safe confirmation. + +Invariants enforced (see CLAUDE.md): + * money is integer paise, never float + * confirmation is transactional with row locks; the booking_slots GIST + exclusion guarantees only one confirmed booking per slot + * losing payers are auto-refunded + * every money movement writes an append-only ledger entry + +Commit policy: create_payment_intent / refund_booking are called from request +handlers and commit themselves. confirm_payment / fail_payment are called from +the webhook handler, which owns the commit. + +Refund safety: Stripe refund calls are wrapped (a failure records a `failed` +Refund row and never aborts the surrounding confirmation) and are idempotent +(a payment already past `succeeded` is not refunded twice). +""" +import logging +from datetime import datetime, timezone + +from sqlalchemy.orm import Session +from sqlalchemy.exc import IntegrityError +from sqlalchemy import func + +from app.core.config import settings +from app.core.stripe_client import get_stripe +from app.core.exceptions import NotFoundError, ForbiddenError, BadRequestError +from app.modules.auth.dependencies import AuthContext +from app.modules.booking.models import ( + Booking, BookingStatus, PaymentStatus, BookingSlot, BookingStatusHistory, +) +from app.modules.booking.service import _booking_out +from sqlalchemy.orm import joinedload, selectinload +from app.modules.profile.models import Profile +from app.modules.booking.state_machine import can_transition +from app.modules.venue.models import Venue +from app.modules.payment.models import ( + Payment, Refund, LedgerEntry, PaymentAttemptStatus, RefundStatus, +) +from app.modules.payment.schemas import ( + PaymentIntentResponse, PaymentResponse, RefundResponse, OwnerLedgerStatsResponse, LedgerEntryResponse +) +from app.modules.notification import service as notifications + +logger = logging.getLogger(__name__) + +ADVANCE = "advance" +BALANCE = "balance" + + +# --------------------------------------------------------------------------- # +# Request-path: create a payment intent (advance or balance) +# --------------------------------------------------------------------------- # +def create_payment_intent( + db: Session, current_user_id, booking_id: str, payment_type: str = ADVANCE +) -> PaymentIntentResponse: + booking = db.query(Booking).filter(Booking.id == booking_id).with_for_update().first() + if not booking: + raise NotFoundError("Booking not found") + if str(booking.user_id) != str(current_user_id): + raise ForbiddenError("You do not own this booking") + + venue = db.get(Venue, booking.venue_id) + if not venue: + raise NotFoundError("Venue not found") + + if payment_type == ADVANCE: + if booking.status not in (BookingStatus.owner_accepted, BookingStatus.payment_pending): + raise BadRequestError("Booking is not awaiting payment") + now = datetime.now(timezone.utc) + if booking.status == BookingStatus.owner_accepted: + if not booking.hold_expires_at or booking.hold_expires_at < now: + raise BadRequestError("Payment hold has expired") + elif booking.status == BookingStatus.payment_pending: + if not booking.payment_expires_at or booking.payment_expires_at < now: + raise BadRequestError("Payment hold has expired") + amount_paise = booking.advance_due_paise + if amount_paise <= 0: + raise BadRequestError("Booking has no advance amount due") + idempotency_key = f"booking-{booking.id}-advance" + elif payment_type == BALANCE: + if booking.status != BookingStatus.confirmed or booking.payment_status != PaymentStatus.advance_paid: + raise BadRequestError("Booking is not awaiting a balance payment") + amount_paise = booking.balance_due_paise + if amount_paise <= 0: + raise BadRequestError("Booking has no balance amount due") + idempotency_key = f"booking-{booking.id}-balance" + elif payment_type == "full": + if booking.status not in (BookingStatus.owner_accepted, BookingStatus.payment_pending): + raise BadRequestError("Booking is not awaiting payment") + now = datetime.now(timezone.utc) + if booking.status == BookingStatus.owner_accepted: + if not booking.hold_expires_at or booking.hold_expires_at < now: + raise BadRequestError("Payment hold has expired") + elif booking.status == BookingStatus.payment_pending: + if not booking.payment_expires_at or booking.payment_expires_at < now: + raise BadRequestError("Payment hold has expired") + amount_paise = booking.quoted_price_paise + if amount_paise <= 0: + raise BadRequestError("Booking has no amount due") + idempotency_key = f"booking-{booking.id}-full" + else: + raise BadRequestError("Invalid payment type") + + stripe = get_stripe() + intent = stripe.PaymentIntent.create( + amount=amount_paise, + currency=settings.stripe_currency, + metadata={ + "booking_id": str(booking.id), + "user_id": str(booking.user_id), + "payment_type": payment_type, + }, + idempotency_key=idempotency_key, + ) + + payment = Payment( + booking_id=booking.id, + amount_paise=amount_paise, + currency=settings.stripe_currency, + status=PaymentAttemptStatus.pending, + stripe_payment_intent_id=intent.id, + stripe_client_secret=intent.client_secret, + payment_type=payment_type, + ) + db.add(payment) + if payment_type in (ADVANCE, "full"): + booking.stripe_advance_payment_intent_id = intent.id + booking.payment_status = PaymentStatus.unpaid + else: + booking.stripe_balance_payment_intent_id = intent.id + db.commit() + db.refresh(payment) + + return PaymentIntentResponse( + payment_id=str(payment.id), + booking_id=str(booking.id), + client_secret=intent.client_secret, + amount_paise=amount_paise, + currency=payment.currency, + status=payment.status.value, + ) + + +# --------------------------------------------------------------------------- # +# Webhook-path: confirm / fail (caller commits) +# --------------------------------------------------------------------------- # +def confirm_payment(db: Session, payment_intent_id: str) -> None: + payment = ( + db.query(Payment) + .filter(Payment.stripe_payment_intent_id == payment_intent_id) + .with_for_update() + .first() + ) + if not payment: + logger.warning("confirm_payment: no payment for intent %s", payment_intent_id) + return + booking = db.query(Booking).filter(Booking.id == payment.booking_id).with_for_update().first() + if not booking: + logger.warning("confirm_payment: no booking for payment %s", payment.id) + return + + if payment.payment_type == BALANCE: + confirm_balance_payment(db, payment, booking) + return + + # ---- advance / full payment confirmation ---- + # Idempotent: already confirmed + if payment.status == PaymentAttemptStatus.succeeded and booking.status == BookingStatus.confirmed: + return + + # Booking left the payable state (hold expired / canceled) before the webhook + # arrived — the money is owed back. + if booking.status not in (BookingStatus.owner_accepted, BookingStatus.payment_pending): + logger.warning("confirm_payment: booking %s in %s, refunding stray payment", booking.id, booking.status) + _record_refund(db, payment, booking, payment.amount_paise, "booking_not_payable") + return + + venue = db.get(Venue, booking.venue_id) + + # Claim the slot(s). The GIST exclusion rejects a second blocking range that + # overlaps an existing one on the same venue -> the loser hits IntegrityError. + slots = db.query(BookingSlot).filter(BookingSlot.booking_id == booking.id).all() + for s in slots: + s.is_blocking = True + try: + db.flush() + except IntegrityError: + db.rollback() + logger.info("confirm_payment: booking %s lost the slot race; conflict-canceling", booking.id) + _conflict_cancel_self_and_refund(db, payment_intent_id) + return + + # Won (or no slots yet). Confirm. + if not can_transition(booking.status, BookingStatus.confirmed): + logger.error("confirm_payment: illegal transition %s -> confirmed", booking.status) + return + + old_status = booking.status + payment.status = PaymentAttemptStatus.succeeded + booking.status = BookingStatus.confirmed + booking.confirmed_at = datetime.now(timezone.utc) + booking.amount_paid_paise = (booking.amount_paid_paise or 0) + payment.amount_paise + + if payment.payment_type == "full": + booking.balance_due_paise = 0 + booking.payment_status = PaymentStatus.fully_paid + else: + booking.payment_status = ( + PaymentStatus.fully_paid if booking.balance_due_paise == 0 else PaymentStatus.advance_paid + ) + + if old_status == BookingStatus.payment_pending: + booking.auto_confirmed_at = datetime.now(timezone.utc) + booking.confirmed_by = "SYSTEM" + else: + booking.confirmed_by = "OWNER" + + owner_id = venue.owner_id if venue else booking.user_id + db.add(LedgerEntry( + booking_id=booking.id, venue_id=booking.venue_id, owner_id=owner_id, + user_id=booking.user_id, entry_type="charge", amount_paise=payment.amount_paise, + direction="credit", stripe_pi_ref=payment_intent_id, + )) + + fee_pct = float(venue.platform_commission_pct) if venue else settings.platform_fee_pct + fee = booking.platform_fee_paise or round(payment.amount_paise * fee_pct / 100) + booking.platform_fee_paise = fee + if fee: + db.add(LedgerEntry( + booking_id=booking.id, venue_id=booking.venue_id, owner_id=owner_id, + user_id=booking.user_id, entry_type="platform_fee", amount_paise=fee, + direction="debit", stripe_pi_ref=payment_intent_id, + )) + + reason_str = "full_payment_succeeded" if payment.payment_type == "full" else "token_payment_succeeded" + db.add(BookingStatusHistory( + booking_id=booking.id, old_status=old_status, + new_status=BookingStatus.confirmed, reason=reason_str, + )) + + # Knock out competitors for the same slot and refund any who already paid. + for competitor in _find_competing_bookings(db, booking): + _conflict_cancel(db, competitor, venue) + + venue_name = venue.name if venue else "your venue" + notifications.notify(db, booking.user_id, "payment_confirmed", + context={"venue_name": venue_name}, booking_id=booking.id) + if venue: + notifications.notify(db, venue.owner_id, "payment_confirmed", + context={"venue_name": venue_name}, booking_id=booking.id) + + +def confirm_balance_payment(db: Session, payment: Payment, booking: Booking) -> None: + """Capture the remaining balance on an already-confirmed booking. + + The slot is already reserved (claimed at advance confirmation), so there is + no slot/conflict work here — only the advance_paid -> fully_paid transition. + """ + # Idempotent: already fully paid + if payment.status == PaymentAttemptStatus.succeeded and booking.payment_status == PaymentStatus.fully_paid: + return + + if booking.status != BookingStatus.confirmed: + logger.warning("confirm_balance_payment: booking %s in %s, refunding stray balance", booking.id, booking.status) + _record_refund(db, payment, booking, payment.amount_paise, "balance_not_payable") + return + + venue = db.get(Venue, booking.venue_id) + owner_id = venue.owner_id if venue else booking.user_id + + payment.status = PaymentAttemptStatus.succeeded + booking.amount_paid_paise = (booking.amount_paid_paise or 0) + payment.amount_paise + booking.payment_status = PaymentStatus.fully_paid + booking.balance_overdue_at = None + booking.owner_action_deadline = None + + db.add(LedgerEntry( + booking_id=booking.id, venue_id=booking.venue_id, owner_id=owner_id, + user_id=booking.user_id, entry_type="charge", amount_paise=payment.amount_paise, + direction="credit", stripe_pi_ref=payment.stripe_payment_intent_id, + )) + + venue_name = venue.name if venue else "your venue" + notifications.notify(db, booking.user_id, "balance_paid", + context={"venue_name": venue_name}, booking_id=booking.id) + if venue: + notifications.notify(db, venue.owner_id, "balance_paid", + context={"venue_name": venue_name}, booking_id=booking.id) + + +def fail_payment(db: Session, payment_intent_id: str) -> None: + payment = ( + db.query(Payment) + .filter(Payment.stripe_payment_intent_id == payment_intent_id) + .with_for_update() + .first() + ) + if not payment: + return + payment.status = PaymentAttemptStatus.failed + # Booking stays in its current state; the hold-expiry / balance-overdue jobs + # reclaim it if no retry succeeds. + + +def cancel_payment_intent(payment_intent_id: str | None) -> None: + """Cancel an uncaptured Stripe PaymentIntent (e.g. a pending advance whose + booking is being cancelled before payment succeeds). + + Resilient like _record_refund: a Stripe failure (already-captured, already + -canceled, or network error) is logged and swallowed so it never aborts the + surrounding cancellation transaction. + """ + if not payment_intent_id: + return + try: + get_stripe().PaymentIntent.cancel(payment_intent_id) + except Exception: # noqa: BLE001 — Stripe/network failure must not abort the txn + logger.exception("Stripe cancel failed for payment intent %s", payment_intent_id) + + +# --------------------------------------------------------------------------- # +# Request-path: owner / admin refund (full) +# --------------------------------------------------------------------------- # +def refund_booking(db: Session, booking_id: str, current_user: AuthContext, reason: str | None) -> RefundResponse: + booking = db.query(Booking).filter(Booking.id == booking_id).with_for_update().first() + if not booking: + raise NotFoundError("Booking not found") + + venue = db.get(Venue, booking.venue_id) + is_venue_owner = current_user.is_owner() and venue and str(venue.owner_id) == str(current_user.user_id) + if not current_user.is_admin() and not is_venue_owner: + raise ForbiddenError("Only the venue owner or an admin can refund this booking") + + payments = _succeeded_payments(db, booking.id) + if not payments: + raise BadRequestError("No captured payment to refund") + + # Full refund: every captured payment (advance + balance) is returned. + refunded = 0 + for p in payments: + refunded += _record_refund(db, p, booking, p.amount_paise, reason or "owner_cancellation") + + if refunded > 0: + booking.payment_status = PaymentStatus.refunded + venue_name = venue.name if venue else "your venue" + notifications.notify(db, booking.user_id, "refund_issued", + context={"venue_name": venue_name, "amount_rupees": refunded // 100}, + booking_id=booking.id) + + if booking.status == BookingStatus.confirmed and can_transition(booking.status, BookingStatus.user_cancelled): + booking.status = BookingStatus.user_cancelled + booking.cancelled_at = datetime.now(timezone.utc) + db.add(BookingStatusHistory( + booking_id=booking.id, old_status=BookingStatus.confirmed, + new_status=BookingStatus.user_cancelled, reason=reason or "owner_cancellation", + )) + # release the slots so they can be rebooked + for s in db.query(BookingSlot).filter(BookingSlot.booking_id == booking.id).all(): + s.is_blocking = False + + db.commit() + return RefundResponse( + booking_id=str(booking.id), refunded_paise=refunded, + status="succeeded" if refunded > 0 else "failed", + ) + + +def refund_for_cancellation(db: Session, booking: Booking, amount_paise: int, reason: str) -> int: + """Refund up to `amount_paise` across a booking's captured payments. + + Used by the booking cancellation flows so their computed policy refunds + (full / partial / goodwill) move real money and write ledger entries. + Does NOT commit and does NOT set booking.payment_status — the caller owns + both (the caller knows whether the result is a full or partial refund). + """ + if amount_paise <= 0: + return 0 + remaining = amount_paise + refunded = 0 + for p in _succeeded_payments(db, booking.id): + if remaining <= 0: + break + take = min(remaining, p.amount_paise) + got = _record_refund(db, p, booking, take, reason) + refunded += got + remaining -= got + return refunded + + +def list_payments_for_booking(db: Session, booking_id: str, current_user: AuthContext) -> list[PaymentResponse]: + booking = db.get(Booking, booking_id) + if not booking: + raise NotFoundError("Booking not found") + venue = db.get(Venue, booking.venue_id) + is_owner_of_booking = str(booking.user_id) == str(current_user.user_id) + is_venue_owner = current_user.is_owner() and venue and str(venue.owner_id) == str(current_user.user_id) + if not (is_owner_of_booking or is_venue_owner or current_user.is_admin()): + raise ForbiddenError("Not allowed to view these payments") + rows = db.query(Payment).filter(Payment.booking_id == booking_id).all() + return [ + PaymentResponse( + id=str(p.id), booking_id=str(p.booking_id), amount_paise=p.amount_paise, + currency=p.currency, status=p.status.value, + stripe_payment_intent_id=p.stripe_payment_intent_id, + ) + for p in rows + ] + + +def get_owner_ledger_stats(db: Session, current_user: AuthContext) -> OwnerLedgerStatsResponse: + if not current_user.is_owner(): + raise ForbiddenError("Must be a venue owner") + + entries = db.query( + LedgerEntry.entry_type, + LedgerEntry.direction, + func.sum(LedgerEntry.amount_paise).label("total") + ).filter( + LedgerEntry.owner_id == current_user.user_id + ).group_by( + LedgerEntry.entry_type, + LedgerEntry.direction + ).all() + + gross_volume = 0 + platform_fees = 0 + refunds_issued = 0 + payouts_completed = 0 + + for entry_type, direction, total in entries: + val = int(total or 0) + if entry_type == "charge" and direction == "credit": + gross_volume += val + elif entry_type == "platform_fee" and direction == "debit": + platform_fees += val + elif entry_type == "refund" and direction == "debit": + refunds_issued += val + elif entry_type == "payout" and direction == "debit": + payouts_completed += val + + net_revenue = gross_volume - platform_fees - refunds_issued + available_balance = net_revenue - payouts_completed + + return OwnerLedgerStatsResponse( + gross_volume_paise=gross_volume, + platform_fees_paise=platform_fees, + refunds_issued_paise=refunds_issued, + net_revenue_paise=net_revenue, + payouts_completed_paise=payouts_completed, + available_balance_paise=available_balance + ) + + +def list_owner_ledger_entries(db: Session, current_user: AuthContext, entry_type: str | None = None) -> list[LedgerEntryResponse]: + if not current_user.is_owner(): + raise ForbiddenError("Must be a venue owner") + + query = ( + db.query(LedgerEntry, Venue, Profile) + .outerjoin(Venue, LedgerEntry.venue_id == Venue.id) + .outerjoin(Profile, LedgerEntry.user_id == Profile.id) + .filter(LedgerEntry.owner_id == current_user.user_id) + ) + + if entry_type and entry_type != "all": + query = query.filter(LedgerEntry.entry_type == entry_type) + + query = query.order_by(LedgerEntry.created_at.desc()) + results = query.all() + + responses = [] + for ledger, venue, profile in results: + responses.append( + LedgerEntryResponse( + id=str(ledger.id), + booking_id=str(ledger.booking_id), + venue_id=str(ledger.venue_id), + venue_name=venue.name if venue else None, + user_full_name=profile.full_name if profile else None, + entry_type=ledger.entry_type, + amount_paise=ledger.amount_paise, + direction=ledger.direction, + stripe_pi_ref=ledger.stripe_pi_ref, + created_at=ledger.created_at.isoformat() + ) + ) + return responses + + +# --------------------------------------------------------------------------- # +# Helpers +# --------------------------------------------------------------------------- # +def _succeeded_payments(db: Session, booking_id) -> list[Payment]: + return ( + db.query(Payment) + .filter(Payment.booking_id == booking_id, Payment.status == PaymentAttemptStatus.succeeded) + .order_by(Payment.created_at.asc()) + .all() + ) + + +def _record_refund(db: Session, payment: Payment, booking: Booking, amount_paise: int, reason: str) -> int: + """Issue a Stripe refund and record it (refund row + ledger debit). + + Resilient: a Stripe failure records a `failed` Refund row and returns 0 + rather than raising (so a competitor-refund failure never rolls back the + winner's confirmation). Idempotent: a payment that is not in `succeeded` + state is skipped (prevents double refunds). Does NOT send notifications — + the caller decides which message to send. + """ + if amount_paise <= 0: + return 0 + if payment.status != PaymentAttemptStatus.succeeded: + logger.info("refund skipped: payment %s not refundable (status=%s)", payment.id, payment.status) + return 0 + + stripe = get_stripe() + try: + refund = stripe.Refund.create( + payment_intent=payment.stripe_payment_intent_id, + amount=amount_paise, + metadata={"booking_id": str(booking.id), "reason": reason}, + ) + except Exception: # noqa: BLE001 — Stripe/network failure must not abort the txn + logger.exception("Stripe refund failed for payment %s (booking %s)", payment.id, booking.id) + db.add(Refund( + payment_id=payment.id, booking_id=booking.id, amount_paise=amount_paise, + reason=reason, status=RefundStatus.failed, stripe_refund_id=None, + )) + return 0 + + db.add(Refund( + payment_id=payment.id, booking_id=booking.id, amount_paise=amount_paise, + reason=reason, status=RefundStatus.succeeded, stripe_refund_id=refund.id, + )) + # Mark the attempt refunded only when the whole captured amount is returned. + if amount_paise >= payment.amount_paise: + payment.status = PaymentAttemptStatus.refunded + booking.refund_amount_paise = (booking.refund_amount_paise or 0) + amount_paise + + venue = db.get(Venue, booking.venue_id) + owner_id = venue.owner_id if venue else booking.user_id + db.add(LedgerEntry( + booking_id=booking.id, venue_id=booking.venue_id, owner_id=owner_id, + user_id=booking.user_id, entry_type="refund", amount_paise=amount_paise, + direction="debit", stripe_pi_ref=payment.stripe_payment_intent_id, + )) + return amount_paise + + +def _find_competing_bookings(db: Session, booking: Booking) -> list[Booking]: + """Other active bookings contending for the same slot. + + Overlap rule: same venue, still requested/owner_accepted, whose effective slot + range intersects this booking's effective slot range. + """ + slot = db.query(BookingSlot).filter(BookingSlot.booking_id == booking.id).first() + if slot is None: + return [] + return ( + db.query(Booking) + .join(BookingSlot, BookingSlot.booking_id == Booking.id) + .filter( + Booking.id != booking.id, + Booking.venue_id == booking.venue_id, + Booking.status.in_([BookingStatus.requested, BookingStatus.owner_accepted, BookingStatus.payment_pending]), + BookingSlot.deleted_at.is_(None), + BookingSlot.effective_starts_at < slot.effective_ends_at, + BookingSlot.effective_ends_at > slot.effective_starts_at, + ) + .with_for_update() + .all() + ) + + +def _conflict_cancel(db: Session, competitor: Booking, venue: Venue | None) -> None: + old = competitor.status + if not can_transition(old, BookingStatus.conflict_cancelled): + logger.warning("skip conflict-cancel: illegal %s -> conflict_cancelled for booking %s", old, competitor.id) + return + competitor.status = BookingStatus.conflict_cancelled + competitor.cancelled_at = datetime.now(timezone.utc) + db.add(BookingStatusHistory( + booking_id=competitor.id, old_status=old, new_status=BookingStatus.conflict_cancelled, + reason="slot_confirmed_by_another", + )) + # Refund any captured payment; the conflict_canceled notice (below) already + # tells the user their money was refunded, so no separate refund_issued. + for paid in _succeeded_payments(db, competitor.id): + _record_refund(db, paid, competitor, paid.amount_paise, "conflict_canceled") + venue_name = venue.name if venue else "the venue" + notifications.notify(db, competitor.user_id, "conflict_canceled", + context={"venue_name": venue_name}, booking_id=competitor.id) + + +def _conflict_cancel_self_and_refund(db: Session, payment_intent_id: str) -> None: + """The current booking lost the slot race — cancel it and refund this payment.""" + payment = ( + db.query(Payment) + .filter(Payment.stripe_payment_intent_id == payment_intent_id) + .with_for_update() + .first() + ) + if not payment: + return + booking = db.query(Booking).filter(Booking.id == payment.booking_id).with_for_update().first() + if not booking: + return + venue = db.get(Venue, booking.venue_id) + old = booking.status + if not can_transition(old, BookingStatus.conflict_cancelled): + logger.warning("skip self conflict-cancel: illegal %s -> conflict_cancelled for booking %s", old, booking.id) + return + booking.status = BookingStatus.conflict_cancelled + booking.cancelled_at = datetime.now(timezone.utc) + db.add(BookingStatusHistory( + booking_id=booking.id, old_status=old, new_status=BookingStatus.conflict_cancelled, + reason="lost_slot_race", + )) + # This payment just succeeded but hasn't been marked succeeded yet — do so + # so the refund guard recognises it as refundable. + payment.status = PaymentAttemptStatus.succeeded + _record_refund(db, payment, booking, payment.amount_paise, "lost_slot_race") + venue_name = venue.name if venue else "the venue" + notifications.notify(db, booking.user_id, "conflict_canceled", + context={"venue_name": venue_name}, booking_id=booking.id) diff --git a/apps/api/app/modules/payment/webhooks.py b/apps/api/app/modules/payment/webhooks.py new file mode 100644 index 000000000..7691e4e95 --- /dev/null +++ b/apps/api/app/modules/payment/webhooks.py @@ -0,0 +1,104 @@ +"""Stripe webhook entrypoint. + +Flow: verify signature -> record the event in stripe_events (idempotency guard, +duplicate = no-op) -> dispatch -> stamp processed_at / processing_error. +""" +import json +import logging +from datetime import datetime, timezone + +from fastapi import Request, HTTPException +from sqlalchemy.exc import IntegrityError + +from app.core.config import settings +from app.core.database import SessionLocal +from app.core.stripe_client import get_stripe +from app.modules.payment.models import StripeEvent +from app.modules.payment import service + +logger = logging.getLogger(__name__) + + +async def handle(request: Request): + payload = await request.body() + sig = request.headers.get("stripe-signature") + stripe = get_stripe() + + if settings.stripe_webhook_secret: + try: + event = stripe.Webhook.construct_event(payload, sig, settings.stripe_webhook_secret) + except Exception as e: # signature / parse failure + logger.warning("Invalid Stripe webhook: %s", e) + raise HTTPException(status_code=400, detail="Invalid signature") + else: + # dev fallback when no signing secret is configured + logger.warning("STRIPE_WEBHOOK_SECRET unset — skipping signature verification") + event = json.loads(payload) + + event_id = event["id"] + event_type = event["type"] + + db = SessionLocal() + try: + # Idempotency guard keyed on the Stripe event id. We record the event + # first, but treat it as a true duplicate ONLY once it has been + # *successfully* processed (processed_at set). An event that was seen but + # failed to process (processing_error, processed_at NULL) is re-dispatched + # when Stripe retries — so a transient failure can never strand a paid + # booking in an unconfirmed state. + stored = db.get(StripeEvent, event_id) + if stored is None: + stored = StripeEvent(id=event_id, type=event_type, raw_payload=_json_safe(event)) + db.add(stored) + try: + db.commit() + except IntegrityError: + # Concurrent delivery inserted it first — reload and continue. + db.rollback() + stored = db.get(StripeEvent, event_id) + + if stored is None: + # Extremely unlikely row contention; ask Stripe to retry. + raise HTTPException(status_code=409, detail="Event contention, retry") + if stored.processed_at is not None: + logger.info("Duplicate Stripe event %s ignored (already processed)", event_id) + return {"status": "duplicate"} + + try: + _dispatch(db, event) + stored = db.get(StripeEvent, event_id) + stored.processed_at = datetime.now(timezone.utc) + stored.processing_error = None + db.commit() + except HTTPException: + raise + except Exception as e: + db.rollback() + stored = db.get(StripeEvent, event_id) + if stored: + stored.processing_error = str(e) + db.commit() + logger.exception("Error processing Stripe event %s", event_id) + raise HTTPException(status_code=500, detail="Processing error") + + return {"status": "ok"} + finally: + db.close() + + +def _dispatch(db, event) -> None: + event_type = event["type"] + obj = event["data"]["object"] + if event_type == "payment_intent.succeeded": + service.confirm_payment(db, obj["id"]) + elif event_type == "payment_intent.payment_failed": + service.fail_payment(db, obj["id"]) + else: + logger.info("Unhandled Stripe event type %s", event_type) + + +def _json_safe(event) -> dict | None: + try: + return json.loads(json.dumps(event, default=str)) + except (TypeError, ValueError): + return None diff --git a/apps/api/app/modules/profile/models.py b/apps/api/app/modules/profile/models.py new file mode 100644 index 000000000..1b316589e --- /dev/null +++ b/apps/api/app/modules/profile/models.py @@ -0,0 +1,56 @@ +import enum +import uuid +from datetime import datetime +from sqlalchemy import String, Enum, DateTime, UniqueConstraint, func, ForeignKey +from sqlalchemy.orm import mapped_column, Mapped, relationship +from sqlalchemy.dialects.postgresql import UUID +from app.core.database import Base + + +class ProfileStatus(str, enum.Enum): + active = "active" + suspended = "suspended" + pending = "pending" + rejected = "rejected" + + +class UserRole(str, enum.Enum): + customer = "customer" + venue_owner = "venue_owner" + super_admin = "super_admin" + + +class Profile(Base): + __tablename__ = "profiles" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True) + email: Mapped[str | None] = mapped_column(String, nullable=True, index=True) + full_name: Mapped[str | None] = mapped_column(String, nullable=True) + phone: Mapped[str | None] = mapped_column(String, nullable=True) + avatar_url: Mapped[str | None] = mapped_column(String, nullable=True) + status: Mapped[ProfileStatus] = mapped_column( + Enum(ProfileStatus, name="profile_status", create_constraint=False), + nullable=False, + default=ProfileStatus.active, + ) + deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=func.now(), onupdate=func.now()) + + bookings = relationship( + "Booking", + back_populates="user", + ) + + +class UserRoleAssignment(Base): + __tablename__ = "user_roles" + __table_args__ = (UniqueConstraint("user_id", "role", name="uq_user_roles_user_role"),) + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("profiles.id", ondelete="CASCADE"), nullable=False) + role: Mapped[UserRole] = mapped_column( + Enum(UserRole, name="user_role", create_constraint=False), + nullable=False, + ) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=func.now()) diff --git a/apps/api/app/modules/profile/routes.py b/apps/api/app/modules/profile/routes.py new file mode 100644 index 000000000..c70240e46 --- /dev/null +++ b/apps/api/app/modules/profile/routes.py @@ -0,0 +1,17 @@ +from fastapi import APIRouter +from app.modules.profile.schemas import ProfileResponse, UpdateProfileRequest +from app.modules.auth.dependencies import get_current_user +from fastapi import Depends +from app.modules.profile import service + +router = APIRouter() + + +@router.get("/me", response_model=ProfileResponse) +def get_profile(user=Depends(get_current_user)): + return service.get_profile(user["sub"]) + + +@router.patch("/me", response_model=ProfileResponse) +def update_profile(body: UpdateProfileRequest, user=Depends(get_current_user)): + return service.update_profile(user["sub"], body) diff --git a/apps/api/app/modules/profile/schemas.py b/apps/api/app/modules/profile/schemas.py new file mode 100644 index 000000000..ed1868e16 --- /dev/null +++ b/apps/api/app/modules/profile/schemas.py @@ -0,0 +1,13 @@ +from pydantic import BaseModel, EmailStr +from typing import Optional + + +class ProfileResponse(BaseModel): + id: str + email: EmailStr + full_name: str + role: str + + +class UpdateProfileRequest(BaseModel): + full_name: Optional[str] = None diff --git a/apps/api/app/modules/profile/service.py b/apps/api/app/modules/profile/service.py new file mode 100644 index 000000000..be131d0fc --- /dev/null +++ b/apps/api/app/modules/profile/service.py @@ -0,0 +1,9 @@ +from app.modules.profile.schemas import ProfileResponse, UpdateProfileRequest + + +def get_profile(user_id: str) -> ProfileResponse: + raise NotImplementedError + + +def update_profile(user_id: str, body: UpdateProfileRequest) -> ProfileResponse: + raise NotImplementedError diff --git a/apps/api/app/modules/search/category_intent.py b/apps/api/app/modules/search/category_intent.py new file mode 100644 index 000000000..2383d30ea --- /dev/null +++ b/apps/api/app/modules/search/category_intent.py @@ -0,0 +1,119 @@ +"""Detects which venue-category "intent" a search query implies, so hybrid +search can boost only the categories the query actually seems to be about +instead of always boosting wedding venues regardless of what was searched. + +Deliberately simple: a token-overlap check against three small term sets. +Not a classifier — just enough to stop "rooftop party bangalore" from +getting a wedding-hall boost it has nothing to do with, and (as of this +version) to stop "corporate offsite bangalore" from boosting rooftop/resort +venues instead of the conference/meeting/auditorium venues it actually +means. + +The boost *group names* (GROUP_WEDDING / GROUP_EVENT / GROUP_CORPORATE) are +the same strings stored in VenueCategory.search_boost_group — see +search_metadata_cache.py — so a category's DB tag and a query's detected +intent line up automatically. + +The boost *magnitudes* live in settings so they can be tuned without a +deploy. +""" + +from app.core.config import settings + +# Must match the values used in VenueCategory.search_boost_group. +GROUP_WEDDING = "wedding_hall_banquet_hall" +GROUP_EVENT = "event_space_rooftop_resort_lawn" +GROUP_CORPORATE = ( + "corporate_conference_meeting" # conference_room, meeting_room, auditorium +) + +# Terms that suggest the query is about wedding/marriage-function venues. +_WEDDING_INTENT_TERMS = { + "wedding", + "marriage", + "reception", + "mandap", + "sadya", + "kalyanam", + "kalyana", + "vivaham", + "nikah", + "shaadi", + "shadi", + "vivah", + "engagement", + "muhurtham", + "sangeet", + "mehendi", + "haldi", + "baraat", + "banquet", +} + +# Terms that suggest the query is about party/event-style venues +# (rooftop, club, resort, lawn, event_space). +# NOTE: "corporate"/"conference" used to live here, which meant a corporate +# offsite query was boosting resort/rooftop/lawn venues instead of the +# conference/meeting/auditorium venues it actually meant. Moved to +# _CORPORATE_INTENT_TERMS below. +_EVENT_INTENT_TERMS = { + "party", + "rooftop", + "club", + "nightclub", + "lounge", + "birthday", + "anniversary", + "celebration", + "resort", + "lawn", + "getaway", + "staycation", + "event", + "dj", + "sundowner", +} + +# Terms that suggest the query is about formal corporate/institutional +# venues (conference rooms, meeting rooms, auditoriums). +_CORPORATE_INTENT_TERMS = { + "corporate", + "conference", + "meeting", + "boardroom", + "seminar", + "convocation", + "offsite", + "training", + "workshop", + "interview", + "auditorium", +} + +NO_BOOST = 1.00 + + +def detect_category_intents(query: str) -> dict[str, float]: + """Return the boost multiplier to apply per category group, based on + whether the (normalized) query's tokens overlap with that group's terms. + Groups with no signal in the query get a neutral 1.0 — i.e. no boost. + """ + tokens = set(query.lower().split()) if query else set() + + wedding_boost = ( + settings.search_wedding_boost if tokens & _WEDDING_INTENT_TERMS else NO_BOOST + ) + event_boost = ( + settings.search_event_boost if tokens & _EVENT_INTENT_TERMS else NO_BOOST + ) + corporate_boost = ( + settings.search_corporate_boost + if tokens & _CORPORATE_INTENT_TERMS + else NO_BOOST + ) + + return { + GROUP_WEDDING: wedding_boost, + GROUP_EVENT: event_boost, + GROUP_CORPORATE: corporate_boost, + } diff --git a/apps/api/app/modules/search/indexer.py b/apps/api/app/modules/search/indexer.py new file mode 100644 index 000000000..181429adc --- /dev/null +++ b/apps/api/app/modules/search/indexer.py @@ -0,0 +1,200 @@ +import logging +import uuid +from datetime import datetime, timezone, timedelta +from uuid import UUID + +from sqlalchemy import text +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session, joinedload + +from app.core.config import settings +from app.infrastructure.embeddings.jina import embed_passage, embed_query +from app.modules.search import search_metadata_cache +from app.modules.search.models import SearchIndexJob +from app.modules.venue.models import Venue + +logger = logging.getLogger(__name__) + +# Exponential backoff delays in seconds per retry attempt index. +# Attempt 0 → immediate, 1 → 5 min, 2 → 15 min, 3 → 1 hr, 4 → 6 hr +_BACKOFF_SECONDS = [0, 300, 900, 3600, 21600] +MAX_RETRIES = len(_BACKOFF_SECONDS) # was a hardcoded `5` in two places below + + +def _redis_client(): + from upstash_redis import Redis + + return Redis(url=settings.upstash_redis_url, token=settings.upstash_redis_token) + + +def enqueue_job(db: Session, entity_id: UUID, operation: str) -> None: + """Insert a pending search index job and try to push to Upstash (fire-and-forget). + + If a pending/processing job already exists for this entity the partial unique + index raises IntegrityError — we treat that as a no-op (the outstanding job + will pick up the latest state when it runs). + """ + job = SearchIndexJob( + entity_type="venue", + entity_id=entity_id, + operation=operation, + ) + db.add(job) + try: + db.flush() + except IntegrityError: + db.rollback() + return + + job_id = str(job.id) + + try: + if settings.upstash_redis_url and settings.upstash_redis_token: + _redis_client().lpush(settings.upstash_search_queue_key, job_id) + except Exception: + # Redis push failure is non-fatal — the APScheduler worker will poll the DB. + pass + + +def _build_search_document(venue: Venue) -> str: + """Rich document for better semantic search""" + parts = [venue.name] + + if venue.description and len(venue.description.strip()) > 10: + parts.append(venue.description.strip()) + + parts.append(f"{venue.city} {venue.state} India") + + if venue.category: + slug = venue.category.slug + parts.append(venue.category.label) + + # Was a hardcoded dict of slug -> synonym string; now sourced from + # VenueCategory.search_keywords via the startup-loaded cache, so + # adding/editing a category's synonyms doesn't require a deploy. + keywords = search_metadata_cache.keywords_for(slug) + if keywords: + parts.append(keywords) + elif not search_metadata_cache.is_loaded(): + logger.warning( + "search_metadata_cache not loaded when indexing venue %s — " + "keyword expansion skipped for this document", + venue.id, + ) + + parts.append(f"capacity {venue.max_capacity} pax people guests") + if venue.min_capacity and venue.min_capacity > 0: + parts.append(f"minimum {venue.min_capacity} guests") + + if venue.amenities: + parts.append(" ".join(a.name for a in venue.amenities)) + + return "\n".join(parts) + + +def _update_fts(db: Session, venue_id: UUID, document: str) -> None: + db.execute( + text( + "UPDATE venues SET search_vector = to_tsvector('english', :doc) WHERE id = :id" + ), + {"doc": document, "id": str(venue_id)}, + ) + + +def generate_query_embedding(query: str) -> list[float]: + """Kept as a thin wrapper so search/service.py doesn't need to import the + embeddings client directly — it only ever talks to the search module.""" + return embed_query(query) + + +def process_job(db: Session, job_id: str) -> None: + """Process a single search index job end-to-end.""" + job = ( + db.query(SearchIndexJob).filter(SearchIndexJob.id == uuid.UUID(job_id)).first() + ) + if not job: + logger.warning("search_indexer: job %s not found", job_id) + return + if job.status != "pending": + logger.debug("search_indexer: job %s already %s, skipping", job_id, job.status) + return + + job.status = "processing" + job.started_at = datetime.now(timezone.utc) + db.commit() + + try: + venue = ( + db.query(Venue) + .options(joinedload(Venue.category), joinedload(Venue.amenities)) + .filter(Venue.id == job.entity_id) + .first() + ) + if not venue: + raise ValueError(f"Venue {job.entity_id} not found") + + document = _build_search_document(venue) + + _update_fts(db, venue.id, document) + + if settings.jina_api_key: + embedding = embed_passage(document) + venue.embedding = embedding + venue.embedding_updated_at = datetime.now(timezone.utc) + + job.status = "completed" + job.completed_at = datetime.now(timezone.utc) + db.commit() + logger.info("search_indexer: job %s completed for venue %s", job_id, venue.id) + + except Exception as exc: + db.rollback() + job = ( + db.query(SearchIndexJob) + .filter(SearchIndexJob.id == uuid.UUID(job_id)) + .first() + ) + if job: + job.retry_count += 1 + job.error_message = str(exc) + job.status = ( + "failed" if job.retry_count < MAX_RETRIES else "failed_permanently" + ) + db.commit() + logger.error("search_indexer: job %s failed (%s)", job_id, exc) + + +def retryable_job_ids(db: Session, limit: int = 10) -> list[str]: + """Return job IDs eligible for processing: pending or failed-with-backoff-elapsed.""" + now = datetime.now(timezone.utc) + pending = ( + db.query(SearchIndexJob) + .filter(SearchIndexJob.status == "pending") + .order_by(SearchIndexJob.created_at.asc()) + .limit(limit) + .all() + ) + + results = list(pending) + + if len(results) < limit: + failed = ( + db.query(SearchIndexJob) + .filter( + SearchIndexJob.status == "failed", + SearchIndexJob.retry_count < MAX_RETRIES, + ) + .order_by(SearchIndexJob.created_at.asc()) + .all() + ) + for job in failed: + if len(results) >= limit: + break + delay = _BACKOFF_SECONDS[min(job.retry_count, len(_BACKOFF_SECONDS) - 1)] + eligible_at = job.created_at.replace(tzinfo=timezone.utc) + timedelta( + seconds=delay + ) + if now >= eligible_at: + results.append(job) + + return [str(j.id) for j in results] diff --git a/apps/api/app/modules/search/models.py b/apps/api/app/modules/search/models.py new file mode 100644 index 000000000..6248f2801 --- /dev/null +++ b/apps/api/app/modules/search/models.py @@ -0,0 +1,21 @@ +import uuid +from datetime import datetime +from sqlalchemy import Integer, Text, DateTime, func +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import mapped_column, Mapped +from app.core.database import Base + + +class SearchIndexJob(Base): + __tablename__ = "search_index_jobs" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + entity_type: Mapped[str] = mapped_column(Text, nullable=False) + entity_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False) + operation: Mapped[str] = mapped_column(Text, nullable=False) # create | update | delete | reindex + status: Mapped[str] = mapped_column(Text, nullable=False, server_default="pending") + retry_count: Mapped[int] = mapped_column(Integer, nullable=False, server_default="0") + error_message: Mapped[str | None] = mapped_column(Text, nullable=True) + started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) diff --git a/apps/api/app/modules/search/query_normalizer.py b/apps/api/app/modules/search/query_normalizer.py new file mode 100644 index 000000000..c59cf4e64 --- /dev/null +++ b/apps/api/app/modules/search/query_normalizer.py @@ -0,0 +1,87 @@ +"""Lightweight typo correction for search queries. + +Fuzzy-matches each token in the raw query against a vocabulary of domain +terms and swaps in the closest match when confidence is high. Intentionally +cheap and dependency-light — no ML, no external calls — just a +pre-processing pass before FTS/vector search runs. + +The vocabulary has two parts: + - _STATIC_SYNONYMS: linguistic spelling variants (e.g. "shaadi"/"vivah") + that aren't really DB data — these stay hardcoded since they're + language, not content. + - dynamic vocabulary: city names + category slugs/labels currently in the + DB, injected at startup via set_dynamic_vocabulary() so new cities or + categories don't require a code change. See + search_metadata_cache.build_query_vocabulary() and main.py's lifespan. +""" + +from rapidfuzz import fuzz, process + +from app.core.config import settings + +_STATIC_SYNONYMS = [ + "wedding", + "marriage", + "reception", + "function", + "mandap", + "sadya", + "kalyanam", + "vivaham", + "engagement", + "muhurtham", + "nikah", + "shaadi", + "mehendi", + "sangeet", + "baraat", +] + +# Populated at startup from the DB — see set_dynamic_vocabulary(). +_dynamic_vocabulary: list[str] = [] + + +def set_dynamic_vocabulary(terms: list[str]) -> None: + """Replace the DB-sourced portion of the vocabulary. Call once at + startup (main.py's lifespan) and again whenever categories/cities + change meaningfully, if you want that reflected without a restart.""" + global _dynamic_vocabulary + _dynamic_vocabulary = terms + + +def _vocabulary() -> list[str]: + return _STATIC_SYNONYMS + _dynamic_vocabulary + + +def normalize_query(q: str) -> str: + """Fuzzy-correct likely typos in each token of a search query. + + Falls back to the original token whenever no confident vocabulary match + is found, so this only ever tightens spelling — it never invents terms + that weren't plausibly intended. + """ + if not q or not q.strip(): + return q + + vocabulary = _vocabulary() + if not vocabulary: + # Dynamic vocabulary hasn't loaded yet (e.g. called before startup + # finished) — fall back to the static synonym list only rather than + # silently no-op'ing typo correction entirely. + vocabulary = _STATIC_SYNONYMS + + tokens = q.strip().split() + corrected_tokens = [] + + for token in tokens: + if len(token) < settings.search_normalizer_min_token_len: + corrected_tokens.append(token) + continue + + match = process.extractOne(token.lower(), vocabulary, scorer=fuzz.ratio) + if match and match[1] >= settings.search_normalizer_match_threshold: + corrected_tokens.append(match[0]) + else: + corrected_tokens.append(token) + + return " ".join(corrected_tokens) diff --git a/apps/api/app/modules/search/routes.py b/apps/api/app/modules/search/routes.py new file mode 100644 index 000000000..382caf4b4 --- /dev/null +++ b/apps/api/app/modules/search/routes.py @@ -0,0 +1,51 @@ +from fastapi import APIRouter, Query, Depends +from sqlalchemy.orm import Session +from app.core.database import get_db +from app.modules.search.schemas import SearchParams, SearchResult +from app.modules.search import service +from app.shared.pagination import Page + +router = APIRouter() + + +def _params( + q: str = Query(default=""), + city: str = Query(default=""), + venue_type: str | None = Query(default=None), + capacity: int = Query(default=0), + page: int = Query(default=1, ge=1), + page_size: int = Query(default=20, ge=1, le=100), +) -> SearchParams: + return SearchParams(q=q, city=city, venue_type=venue_type, capacity=capacity, page=page, page_size=page_size) + + +@router.get("/", response_model=Page[SearchResult]) +def search_venues( + params: SearchParams = Depends(_params), + db: Session = Depends(get_db), +): + return service.search(db, params) + + +@router.get("/fts", response_model=Page[SearchResult]) +def search_fts( + params: SearchParams = Depends(_params), + db: Session = Depends(get_db), +): + return service.search_fts(db, params) + + +@router.get("/semantic", response_model=Page[SearchResult]) +def search_semantic( + params: SearchParams = Depends(_params), + db: Session = Depends(get_db), +): + return service.search_semantic(db, params) + + +@router.get("/hybrid", response_model=Page[SearchResult]) +def search_hybrid( + params: SearchParams = Depends(_params), + db: Session = Depends(get_db), +): + return service.search_hybrid(db, params) diff --git a/apps/api/app/modules/search/schemas.py b/apps/api/app/modules/search/schemas.py new file mode 100644 index 000000000..59066fa19 --- /dev/null +++ b/apps/api/app/modules/search/schemas.py @@ -0,0 +1,36 @@ +from pydantic import BaseModel +from typing import Optional +from uuid import UUID +from app.modules.venue.schemas import VenueCategoryResponse + + +class SearchParams(BaseModel): + q: str = "" + city: str = "" + venue_type: Optional[str] = None # slug — kept for URL backward compat + capacity: int = 0 + page: int = 1 + page_size: int = 20 + + +class SearchResult(BaseModel): + id: UUID + name: str + city: str + category: VenueCategoryResponse + capacity: int + pricing_mode: str + starting_price_paise: Optional[int] = None + display_price_min_paise: Optional[int] = None + display_price_max_paise: Optional[int] = None + cover_photo_url: Optional[str] = None + is_liked: bool = False + # Match diagnostics — only populated by search_hybrid (None for plain + # /search, /search/fts, /search/semantic). Lets a caller (e.g. Deep + # Research's citation UI) show which signal(s) actually matched and how + # strongly, without re-deriving it client-side. + match_source: Optional[str] = None # "hybrid" | "semantic" | "keyword" + fts_score: Optional[float] = None + vector_score: Optional[float] = None + category_boost: Optional[float] = None + match_score: Optional[float] = None diff --git a/apps/api/app/modules/search/search_metadata_cache.py b/apps/api/app/modules/search/search_metadata_cache.py new file mode 100644 index 000000000..29b5941bd --- /dev/null +++ b/apps/api/app/modules/search/search_metadata_cache.py @@ -0,0 +1,107 @@ +"""In-memory cache of search-relevant metadata, populated from the DB at +app startup (and reloadable on demand from the admin routes). + +This exists to kill three copies of the same information that used to live +as hardcoded Python literals: + - indexer.py's per-slug keyword-expansion dict + - service.py's SQL boost CASE slug lists + - query_normalizer.py's hardcoded city/category vocabulary + +Now there's one source of truth: VenueCategory.search_boost_group and +VenueCategory.search_keywords columns (admin-editable), plus the distinct +city/slug values already in the venues/venue_categories tables. + +Requires two new columns on VenueCategory — see the accompanying migration +`XXXX_add_search_metadata_to_venue_categories.py`: + search_boost_group: str | None # e.g. "wedding_hall_banquet_hall" + search_keywords: str | None # space-separated synonym string +""" + +import logging +from threading import Lock + +from sqlalchemy.orm import Session + +from app.modules.venue.models import Venue, VenueCategory + +logger = logging.getLogger(__name__) + +_lock = Lock() +_boost_groups: dict[str, str] = {} +_keywords: dict[str, str] = {} +_loaded = False + + +def load_category_metadata(db: Session) -> None: + """Populate the boost-group / keyword cache from the DB. Call once at + startup, and again whenever a category's search metadata is edited via + the admin routes (call reload from there rather than waiting for a + restart).""" + global _boost_groups, _keywords, _loaded + + categories = db.query(VenueCategory).all() + boost_groups = { + c.slug: c.search_boost_group for c in categories if c.search_boost_group + } + keywords = {c.slug: c.search_keywords for c in categories if c.search_keywords} + + with _lock: + _boost_groups = boost_groups + _keywords = keywords + _loaded = True + + logger.info( + "search_metadata_cache: loaded %d categories (%d with boost group, %d with keywords)", + len(categories), + len(boost_groups), + len(keywords), + ) + + +def boost_group_for(slug: str | None) -> str | None: + if not slug: + return None + return _boost_groups.get(slug) + + +def keywords_for(slug: str | None) -> str | None: + if not slug: + return None + return _keywords.get(slug) + + +def slugs_in_group(group: str) -> list[str]: + """Slugs tagged with a given boost group, e.g. all slugs tagged + 'wedding_hall_banquet_hall'. Used to build the SQL boost CASE at query + time instead of a hardcoded IN (...) list.""" + return [slug for slug, g in _boost_groups.items() if g == group] + + +def is_loaded() -> bool: + return _loaded + + +def build_query_vocabulary(db: Session) -> list[str]: + """Distinct city names + category labels/slugs currently in the DB, + for the query normalizer's fuzzy-match vocabulary. Combine this with a + small static list of linguistic synonyms (spelling variants like + "shaadi"/"vivah") that aren't really DB data — that part stays in + query_normalizer.py. + """ + cities = [ + row[0] + for row in db.query(Venue.city).distinct().filter(Venue.city.isnot(None)).all() + if row[0] + ] + categories = db.query(VenueCategory).all() + category_terms: list[str] = [] + for c in categories: + category_terms.append(c.slug.replace("_", " ")) + if c.label: + category_terms.append(c.label.lower()) + + vocabulary = sorted({t.lower() for t in cities + category_terms if t}) + logger.info( + "search_metadata_cache: built %d dynamic vocabulary terms", len(vocabulary) + ) + return vocabulary diff --git a/apps/api/app/modules/search/service.py b/apps/api/app/modules/search/service.py new file mode 100644 index 000000000..1a91f7be4 --- /dev/null +++ b/apps/api/app/modules/search/service.py @@ -0,0 +1,532 @@ +import logging +from uuid import UUID + +import numpy as np +from sqlalchemy import func as sa_func, text, or_ +from sqlalchemy.orm import Session, joinedload + +from app.modules.search import search_metadata_cache +from app.modules.search.category_intent import ( + GROUP_CORPORATE, + GROUP_EVENT, + GROUP_WEDDING, + detect_category_intents, +) +from app.modules.search.query_normalizer import normalize_query +from app.modules.search.schemas import SearchParams, SearchResult +from app.modules.venue.models import Venue, VenueCategory, VenueStatus, VenuePhoto +from app.shared.pagination import Page +from app.core.config import settings + +logger = logging.getLogger(__name__) + + +def _base_query(db: Session, params: SearchParams): + """Base approved/active venue query with city, type, and capacity filters applied.""" + query = ( + db.query(Venue) + .options(joinedload(Venue.category)) + .filter( + Venue.status == VenueStatus.approved, + Venue.is_active == True, + Venue.deleted_at.is_(None), + ) + ) + if params.city: + query = query.filter(Venue.city.ilike(f"%{params.city}%")) + if params.venue_type: + query = query.join(VenueCategory, Venue.category_id == VenueCategory.id).filter( + VenueCategory.slug == params.venue_type + ) + if params.capacity > 0: + query = query.filter(Venue.max_capacity >= params.capacity) + return query + + +def _cover_photos(db: Session, venue_ids: list) -> dict: + if not venue_ids: + return {} + photos = ( + db.query(VenuePhoto) + .filter( + VenuePhoto.venue_id.in_(venue_ids), + VenuePhoto.is_cover == True, + VenuePhoto.deleted_at.is_(None), + ) + .all() + ) + return {p.venue_id: p.image_url for p in photos} + + +def _match_source(fts_matched: bool, vector_matched: bool) -> str: + if fts_matched and vector_matched: + return "hybrid" + if vector_matched: + return "semantic" + return "keyword" + + +def _to_results( + venues: list[Venue], cover_photos: dict, scores: dict | None = None +) -> list[SearchResult]: + scores = scores or {} + results = [] + for v in venues: + starting_price = v.starting_price_paise if v.pricing_mode in ('flat', 'mixed') else v.hourly_rate_paise + row_scores = scores.get(v.id) + results.append(SearchResult( + id=v.id, + name=v.name, + city=v.city, + category=v.category, + capacity=v.max_capacity, + pricing_mode=v.pricing_mode, + starting_price_paise=starting_price, + display_price_min_paise=v.display_price_min_paise, + display_price_max_paise=v.display_price_max_paise, + cover_photo_url=cover_photos.get(v.id), + match_source=( + _match_source(row_scores["fts_matched"], row_scores["vector_matched"]) + if row_scores + else None + ), + fts_score=row_scores["fts_score"] if row_scores else None, + vector_score=row_scores["vector_score"] if row_scores else None, + category_boost=row_scores["boost"] if row_scores else None, + match_score=row_scores["hybrid_score"] if row_scores else None, + )) + return results + + +def search(db: Session, params: SearchParams) -> Page[SearchResult]: + query = ( + db.query(Venue) + .options(joinedload(Venue.category)) + .filter( + Venue.status == VenueStatus.approved, + Venue.is_active == True, + Venue.deleted_at.is_(None), + ) + ) + + if params.q: + search_term = f"%{params.q}%" + query = query.filter( + or_( + Venue.name.ilike(search_term), + Venue.description.ilike(search_term), + Venue.city.ilike(search_term), + Venue.state.ilike(search_term), + ) + ) + + if params.city: + query = query.filter(Venue.city.ilike(f"%{params.city}%")) + + if params.venue_type: + query = query.join(VenueCategory, Venue.category_id == VenueCategory.id).filter( + VenueCategory.slug == params.venue_type + ) + + if params.capacity > 0: + query = query.filter(Venue.max_capacity >= params.capacity) + + total_count = query.count() + + offset = (params.page - 1) * params.page_size + venues = ( + query.order_by(Venue.created_at.desc()) + .offset(offset) + .limit(params.page_size) + .all() + ) + + venue_ids = [v.id for v in venues] + cover_photos = {} + if venue_ids: + photos = db.query(VenuePhoto).filter( + VenuePhoto.venue_id.in_(venue_ids), + VenuePhoto.is_cover == True, + VenuePhoto.deleted_at.is_(None), + ).all() + cover_photos = {p.venue_id: p.image_url for p in photos} + + results = [] + for v in venues: + starting_price = v.starting_price_paise if v.pricing_mode in ('flat', 'mixed') else v.hourly_rate_paise + results.append(SearchResult( + id=v.id, + name=v.name, + city=v.city, + category=v.category, + capacity=v.max_capacity, + pricing_mode=v.pricing_mode, + starting_price_paise=starting_price, + display_price_min_paise=v.display_price_min_paise, + display_price_max_paise=v.display_price_max_paise, + cover_photo_url=cover_photos.get(v.id), + )) + + return Page( + items=_to_results(venues, covers), + total=total_count, + page=params.page, + page_size=params.page_size, + ) + + +# ── FTS search ──────────────────────────────────────────────────────────────── + + +def search_fts(db: Session, params: SearchParams) -> Page[SearchResult]: + """Full-text search ranked by ts_rank. Requires search_vector to be populated.""" + query = _base_query(db, params) + + if params.q: + ts_query = sa_func.plainto_tsquery("english", params.q) + query = query.filter( + Venue.search_vector.op("@@")(ts_query), + ).order_by(sa_func.ts_rank(Venue.search_vector, ts_query).desc()) + else: + query = query.order_by(Venue.created_at.desc()) + + total_count = query.count() + offset = (params.page - 1) * params.page_size + venues = query.offset(offset).limit(params.page_size).all() + covers = _cover_photos(db, [v.id for v in venues]) + return Page( + items=_to_results(venues, covers), + total=total_count, + page=params.page, + page_size=params.page_size, + ) + + +# ── Semantic search ─────────────────────────────────────────────────────────── + + +def search_semantic(db: Session, params: SearchParams) -> Page[SearchResult]: + """Vector similarity search using Jina embeddings. Requires embedding to be populated.""" + from app.modules.search.indexer import generate_query_embedding + + query = _base_query(db, params).filter(Venue.embedding.isnot(None)) + + if params.q: + try: + query_vec = generate_query_embedding(params.q) + query = query.order_by(Venue.embedding.op("<=>")(query_vec).asc()) + except Exception as exc: + logger.warning( + "search_semantic: embedding generation failed (%s), falling back to FTS", + exc, + ) + return search_fts(db, params) + else: + query = query.order_by(Venue.created_at.desc()) + + total_count = query.count() + offset = (params.page - 1) * params.page_size + venues = query.offset(offset).limit(params.page_size).all() + covers = _cover_photos(db, [v.id for v in venues]) + return Page( + items=_to_results(venues, covers), + total=total_count, + page=params.page, + page_size=params.page_size, + ) + + +# ── Hybrid search ───────────────────────────────────────────────────────────── + + +def _has_any_embeddings(db: Session) -> bool: + """Cheap existence check — was `.limit(1).count()`, which still runs a + count aggregate. `.exists()` short-circuits at the first matching row.""" + exists_query = ( + db.query(Venue.id) + .filter( + Venue.status == VenueStatus.approved, + Venue.is_active == True, + Venue.deleted_at.is_(None), + Venue.embedding.isnot(None), + ) + .exists() + ) + return db.query(exists_query).scalar() + + +def _log_hybrid_diagnostics( + db: Session, + raw_query: str, + normalized_q: str, + query_vec: list[float], + intents: dict, +) -> None: + """Debug diagnostics comparing the FTS-only and vector-only result sets. + + Runs 3 extra queries — only call this when settings.search_diagnostics_enabled + is on (dev/staging), not unconditionally on every production request. + """ + try: + vec_norm = float(np.linalg.norm(np.array(query_vec, dtype=np.float32))) + + common_where = """ + v.status = 'approved' AND v.is_active = true AND v.deleted_at IS NULL + """ + + fts_count_sql = text(f""" + SELECT COUNT(*) FROM venues v + WHERE {common_where} + AND v.search_vector @@ plainto_tsquery('english', :q) + """) + fts_matches = db.execute(fts_count_sql, {"q": normalized_q}).scalar() or 0 + + fts_top_sql = text(f""" + SELECT v.id FROM venues v + WHERE {common_where} + AND v.search_vector @@ plainto_tsquery('english', :q) + ORDER BY ts_rank(v.search_vector, plainto_tsquery('english', :q)) DESC + LIMIT 10 + """) + fts_top_ids = [ + str(r.id) for r in db.execute(fts_top_sql, {"q": normalized_q}).fetchall() + ] + + vec_top_sql = text(f""" + SELECT v.id FROM venues v + WHERE {common_where} + AND v.embedding IS NOT NULL + ORDER BY v.embedding <=> CAST(:qvec AS vector) ASC + LIMIT 10 + """) + vec_top_ids = [ + str(r.id) + for r in db.execute(vec_top_sql, {"qvec": str(query_vec)}).fetchall() + ] + + overlap = len(set(fts_top_ids) & set(vec_top_ids)) + + logger.info( + "search_hybrid diagnostics | " + "raw_query=%r normalized_query=%r fts_matches=%d " + "query_vec_norm=%.4f vector_threshold=%.2f " + "fts_top10=%s vector_top10=%s overlap=%d " + "wedding_boost=%.2f event_boost=%.2f", + raw_query, + normalized_q, + fts_matches, + vec_norm, + settings.search_min_vector_similarity, + fts_top_ids, + vec_top_ids, + overlap, + intents.get(GROUP_WEDDING, 1.0), + intents.get(GROUP_EVENT, 1.0), + ) + except Exception: + logger.exception("search_hybrid: diagnostics logging failed") + + +def _log_hybrid_result_scores(rows, limit: int = 20) -> None: + """Log the FTS/vector/boost/hybrid score breakdown for top results.""" + try: + lines = [ + f" #{i+1:>2} id={row.id} cat={row.category_slug or '-':<14} " + f"fts={row.fts_score:.4f} vec={row.vector_score:.4f} " + f"boost={row.boost:.2f} hybrid={row.hybrid_score:.4f} name={row.name!r}" + for i, row in enumerate(rows[:limit]) + ] + logger.info( + "search_hybrid result scores (top %s):\n%s", len(lines), "\n".join(lines) + ) + except Exception: + logger.exception("search_hybrid: result score logging failed") + + +def search_hybrid(db: Session, params: SearchParams) -> Page[SearchResult]: + """Hybrid Search using Full-Text Search + Vector Search + Category Boost""" + + if not params.q: + return search_fts(db, params) + + raw_query = params.q + normalized_q = normalize_query(raw_query) + + if not _has_any_embeddings(db): + return search_fts(db, params) + + from app.modules.search.indexer import generate_query_embedding + + try: + query_vec = generate_query_embedding(normalized_q) + except Exception as exc: + logger.warning("Embedding generation failed: %s", exc) + return search_fts(db, params) + + intents = detect_category_intents(normalized_q) + + if settings.search_diagnostics_enabled: + _log_hybrid_diagnostics(db, raw_query, normalized_q, query_vec, intents) + + # Slug lists per boost group now come from the DB-backed cache instead + # of being hardcoded here — see search_metadata_cache.py. Passed as + # array bind params (`= ANY(:param)`), not string-interpolated, so a + # category added via the admin panel doesn't need a redeploy. + wedding_slugs = search_metadata_cache.slugs_in_group(GROUP_WEDDING) + event_slugs = search_metadata_cache.slugs_in_group(GROUP_EVENT) + corporate_slugs = search_metadata_cache.slugs_in_group(GROUP_CORPORATE) + + base_filters = [ + "v.status = 'approved'", + "v.is_active = true", + "v.deleted_at IS NULL", + """ + ( + v.search_vector @@ plainto_tsquery('english', :q) + + OR + + ( + v.embedding IS NOT NULL + AND NOT ( + v.search_vector @@ plainto_tsquery('english', :q) + ) + AND ( + 1 - ( + v.embedding <=> CAST(:qvec AS vector) + ) + ) >= :min_vector_score + ) + ) + """, + ] + + query_params = { + "q": normalized_q, + "qvec": str(query_vec), + "min_vector_score": settings.search_min_vector_similarity, + # Boost values and slug lists are now bind params rather than + # f-string-interpolated literals: the SQL *text* is identical across + # calls regardless of which intent was detected, so Postgres can + # reuse a cached plan instead of re-planning every query. + "wedding_slugs": wedding_slugs, + "event_slugs": event_slugs, + "corporate_slugs": corporate_slugs, + "wedding_boost": intents.get(GROUP_WEDDING, 1.0), + "event_boost": intents.get(GROUP_EVENT, 1.0), + "corporate_boost": intents.get(GROUP_CORPORATE, 1.0), + "fts_weight": settings.search_fts_weight, + "vector_weight": settings.search_vector_weight, + } + + if params.city: + base_filters.append("v.city ILIKE :city") + query_params["city"] = f"%{params.city}%" + + if params.capacity > 0: + base_filters.append("v.max_capacity >= :capacity") + query_params["capacity"] = params.capacity + + if params.venue_type: + base_filters.append("vc.slug = :venue_type") + query_params["venue_type"] = params.venue_type + + where_clause = " AND ".join(base_filters) + + query_params["limit"] = params.page_size + query_params["offset"] = (params.page - 1) * params.page_size + + # Boost/score computed once in a CTE (was duplicated inline in both the + # `boost` column and `hybrid_score` expression) and total count comes + # from a window function on the same query instead of a separate + # COUNT(*) round trip over the whole WHERE clause. + rows_sql = text(f""" + WITH scored AS ( + SELECT + v.id, + v.name, + vc.slug AS category_slug, + COALESCE( + ts_rank(v.search_vector, plainto_tsquery('english', :q)), + 0 + ) AS fts_score, + COALESCE( + 1 - (v.embedding <=> CAST(:qvec AS vector)), + 0 + ) AS vector_score, + ( + v.search_vector @@ plainto_tsquery('english', :q) + ) AS fts_matched, + ( + v.embedding IS NOT NULL + AND (1 - (v.embedding <=> CAST(:qvec AS vector))) >= :min_vector_score + ) AS vector_matched, + CASE + WHEN vc.slug = ANY(:wedding_slugs) THEN :wedding_boost + WHEN vc.slug = ANY(:event_slugs) THEN :event_boost + WHEN vc.slug = ANY(:corporate_slugs) THEN :corporate_boost + ELSE 1.00 + END AS boost + FROM venues v + LEFT JOIN venue_categories vc ON vc.id = v.category_id + WHERE {where_clause} + ) + SELECT + id, + name, + category_slug, + fts_score, + vector_score, + fts_matched, + vector_matched, + boost, + (:fts_weight * fts_score + :vector_weight * vector_score) * boost AS hybrid_score, + COUNT(*) OVER() AS total_count + FROM scored + ORDER BY hybrid_score DESC + LIMIT :limit + OFFSET :offset + """) + + rows = db.execute(rows_sql, query_params).fetchall() + + if settings.search_diagnostics_enabled: + _log_hybrid_result_scores(rows) + + if not rows: + return Page(items=[], total=0, page=params.page, page_size=params.page_size) + + total = rows[0].total_count + venue_ids: list[UUID] = [row.id for row in rows] + scores = { + row.id: { + "fts_score": row.fts_score, + "vector_score": row.vector_score, + "fts_matched": row.fts_matched, + "vector_matched": row.vector_matched, + "boost": row.boost, + "hybrid_score": row.hybrid_score, + } + for row in rows + } + + # Fetch full venue objects with relationships + venues_by_id = { + venue.id: venue + for venue in ( + db.query(Venue) + .options(joinedload(Venue.category)) + .filter(Venue.id.in_(venue_ids)) + .all() + ) + } + + venues = [venues_by_id[vid] for vid in venue_ids if vid in venues_by_id] + covers = _cover_photos(db, venue_ids) + + return Page( + items=_to_results(venues, covers, scores), + total=total, + page=params.page, + page_size=params.page_size, + ) diff --git a/apps/api/app/modules/venue/models.py b/apps/api/app/modules/venue/models.py new file mode 100644 index 000000000..3c5956f8e --- /dev/null +++ b/apps/api/app/modules/venue/models.py @@ -0,0 +1,381 @@ +import enum +import uuid +from datetime import datetime, date, time +from typing import Any +from sqlalchemy import ( + Integer, Boolean, Numeric, BigInteger, Text, Time, DateTime, Date, + ForeignKey, CheckConstraint, Index, func, Enum, text +) +from sqlalchemy.dialects.postgresql import UUID, ARRAY, TSVECTOR +from sqlalchemy.orm import mapped_column, Mapped, relationship +from pgvector.sqlalchemy import Vector +from app.core.database import Base + + +class VenueStatus(str, enum.Enum): + draft = "draft" + pending_approval = "pending_approval" + approved = "approved" + rejected = "rejected" + suspended = "suspended" + + +class BookingMode(str, enum.Enum): + MANUAL = "MANUAL" + INSTANT = "INSTANT" + + + +class VenueCategory(Base): + __tablename__ = "venue_categories" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + slug: Mapped[str] = mapped_column(Text, nullable=False) + label: Mapped[str] = mapped_column(Text, nullable=False) + icon: Mapped[str | None] = mapped_column(Text, nullable=True) + banner_image: Mapped[str | None] = mapped_column(Text, nullable=True) + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="true") + sort_order: Mapped[int] = mapped_column(Integer, nullable=False, server_default="0") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + search_boost_group: Mapped[str | None] = mapped_column(Text, nullable=True) + search_keywords: Mapped[str | None] = mapped_column(Text, nullable=True) + + __table_args__ = ( + Index("uq_venue_categories_slug_active", "slug", unique=True, postgresql_where=text("deleted_at IS NULL")), + ) + + venues: Mapped[list["Venue"]] = relationship(back_populates="category") + + +class Venue(Base): + __tablename__ = "venues" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + owner_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("profiles.id"), nullable=False) + + name: Mapped[str] = mapped_column(Text, nullable=False) + slug: Mapped[str | None] = mapped_column(Text, unique=True, nullable=True) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + category_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("venue_categories.id"), nullable=False) + + address_line1: Mapped[str] = mapped_column(Text, nullable=False) + address_line2: Mapped[str | None] = mapped_column(Text, nullable=True) + city: Mapped[str] = mapped_column(Text, nullable=False) + state: Mapped[str] = mapped_column(Text, nullable=False) + country: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'India'")) + postal_code: Mapped[str | None] = mapped_column(Text, nullable=True) + latitude: Mapped[float | None] = mapped_column(Numeric(10, 7), nullable=True) + longitude: Mapped[float | None] = mapped_column(Numeric(10, 7), nullable=True) + timezone: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'Asia/Kolkata'")) + + min_capacity: Mapped[int | None] = mapped_column(Integer, nullable=True) + max_capacity: Mapped[int] = mapped_column(Integer, nullable=False) + + open_time: Mapped[time] = mapped_column(Time, nullable=False) + close_time: Mapped[time] = mapped_column(Time, nullable=False) + spans_next_day: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false") + + allowed_booking_types: Mapped[list[str]] = mapped_column(ARRAY(Text), nullable=False, default=lambda: ["full_day", "time_slot"], server_default=text("ARRAY['full_day','time_slot']")) + min_booking_duration_minutes: Mapped[int] = mapped_column(Integer, nullable=False, server_default="60") + max_booking_duration_minutes: Mapped[int] = mapped_column(Integer, nullable=False, server_default="1440") + slot_interval_minutes: Mapped[int] = mapped_column(Integer, nullable=False, server_default="30") + + pre_buffer_minutes: Mapped[int] = mapped_column(Integer, nullable=False, server_default="0") + post_buffer_minutes: Mapped[int] = mapped_column(Integer, nullable=False, server_default="0") + + pricing_mode: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'flat'")) + starting_price_paise: Mapped[int | None] = mapped_column(BigInteger, nullable=True) + hourly_rate_paise: Mapped[int | None] = mapped_column(BigInteger, nullable=True) + + platform_commission_pct: Mapped[float] = mapped_column(Numeric(5, 2), nullable=False, server_default="10.00") + advance_pct: Mapped[float] = mapped_column(Numeric(5, 2), nullable=False, server_default="30.00") + + min_price_pct: Mapped[float] = mapped_column(Numeric(5, 2), nullable=False, server_default="50.00") + max_price_pct: Mapped[float] = mapped_column(Numeric(5, 2), nullable=False, server_default="200.00") + display_price_min_paise: Mapped[int | None] = mapped_column(BigInteger, nullable=True) + display_price_max_paise: Mapped[int | None] = mapped_column(BigInteger, nullable=True) + + balance_due_days_before_event: Mapped[int] = mapped_column(Integer, nullable=False, server_default="7") + owner_action_window_hours: Mapped[int] = mapped_column(Integer, nullable=False, server_default="48") + overdue_advance_refund_pct: Mapped[float] = mapped_column(Numeric(5, 2), nullable=False, server_default="0.00") + + status: Mapped[VenueStatus] = mapped_column( + Enum(VenueStatus, name="venue_status"), + nullable=False, + server_default=text("'draft'") + ) + booking_mode: Mapped[str] = mapped_column( + Text, + nullable=False, + server_default=text("'MANUAL'") + ) + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="true") + last_completed_step: Mapped[int] = mapped_column(Integer, nullable=False, server_default="0") + + search_vector: Mapped[Any | None] = mapped_column(TSVECTOR, nullable=True) + embedding: Mapped[list[float] | None] = mapped_column(Vector(1024), nullable=True) + embedding_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()) + + # Relationships + category: Mapped["VenueCategory"] = relationship(back_populates="venues") + photos: Mapped[list["VenuePhoto"]] = relationship( + back_populates="venue", + cascade="all, delete-orphan", + primaryjoin="and_(Venue.id==VenuePhoto.venue_id, VenuePhoto.deleted_at.is_(None))", + order_by="VenuePhoto.sort_order" + ) + amenities: Mapped[list["Amenity"]] = relationship( + secondary="venue_amenities", + back_populates="venues", + secondaryjoin="and_(VenueAmenity.amenity_id==Amenity.id, Amenity.deleted_at.is_(None))" + ) + availability: Mapped[list["VenueAvailability"]] = relationship( + back_populates="venue", + cascade="all, delete-orphan", + primaryjoin="and_(Venue.id==VenueAvailability.venue_id, VenueAvailability.deleted_at.is_(None))" + ) + blocked_dates: Mapped[list["VenueBlockedDate"]] = relationship( + back_populates="venue", + cascade="all, delete-orphan", + primaryjoin="and_(Venue.id==VenueBlockedDate.venue_id, VenueBlockedDate.deleted_at.is_(None))" + ) + cancellation_policy: Mapped["VenueCancellationPolicy"] = relationship(back_populates="venue", uselist=False, cascade="all, delete-orphan") + + __table_args__ = ( + CheckConstraint("min_capacity IS NULL OR min_capacity <= max_capacity", name="ck_venues_capacity"), + CheckConstraint("min_capacity > 0", name="ck_venues_min_capacity"), + CheckConstraint("max_capacity > 0", name="ck_venues_max_capacity"), + CheckConstraint("min_booking_duration_minutes > 0", name="ck_venues_min_duration"), + CheckConstraint("max_booking_duration_minutes > 0", name="ck_venues_max_duration"), + CheckConstraint("slot_interval_minutes > 0", name="ck_venues_slot_interval"), + CheckConstraint("min_booking_duration_minutes <= max_booking_duration_minutes", name="ck_venues_duration_range"), + CheckConstraint("pre_buffer_minutes >= 0", name="ck_venues_pre_buffer"), + CheckConstraint("post_buffer_minutes >= 0", name="ck_venues_post_buffer"), + CheckConstraint("pricing_mode IN ('flat', 'hourly', 'mixed')", name="ck_venues_pricing_mode"), + CheckConstraint("starting_price_paise >= 0", name="ck_venues_base_price"), + CheckConstraint("hourly_rate_paise >= 0", name="ck_venues_hourly_rate"), + CheckConstraint("platform_commission_pct >= 0 AND platform_commission_pct <= 100", name="ck_venues_commission"), + CheckConstraint("advance_pct > 0 AND advance_pct <= 100", name="ck_venues_advance_pct"), + CheckConstraint("balance_due_days_before_event > 0", name="ck_venues_balance_days"), + CheckConstraint("owner_action_window_hours BETWEEN 24 AND 72", name="ck_venues_action_window"), + CheckConstraint("overdue_advance_refund_pct BETWEEN 0 AND 100", name="ck_venues_overdue_refund_pct"), + CheckConstraint("min_price_pct > 0 AND min_price_pct <= 100", name="ck_venues_min_price_pct"), + CheckConstraint("max_price_pct >= 100 AND max_price_pct <= 500", name="ck_venues_max_price_pct"), + CheckConstraint("min_price_pct <= max_price_pct", name="ck_venues_price_pct_range"), + Index("idx_venues_search", "city", "category_id", "status", "is_active", postgresql_where=text("deleted_at IS NULL")), + ) + + bookings = relationship( + "Booking", + back_populates="venue", + ) + + pricing_rules: Mapped[list["VenuePricingRule"]] = relationship( + back_populates="venue", + cascade="all, delete-orphan", + primaryjoin="and_(Venue.id==VenuePricingRule.venue_id, VenuePricingRule.deleted_at.is_(None))", + order_by="VenuePricingRule.priority.desc()", + ) + + +class VenuePhoto(Base): + __tablename__ = "venue_photos" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + venue_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("venues.id"), nullable=False) + image_url: Mapped[str] = mapped_column(Text, nullable=False) + sort_order: Mapped[int] = mapped_column(Integer, nullable=False, server_default="0") + is_cover: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false") + + deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + + # Relationships + venue: Mapped["Venue"] = relationship(back_populates="photos") + + __table_args__ = ( + Index("venue_photos_one_cover", "venue_id", unique=True, postgresql_where=text("is_cover = true AND deleted_at IS NULL")), + ) + + +class Amenity(Base): + __tablename__ = "amenities" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + name: Mapped[str] = mapped_column(Text, nullable=False) + icon: Mapped[str | None] = mapped_column(Text, nullable=True) + + deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + + __table_args__ = ( + Index("uq_amenities_name_active", "name", unique=True, postgresql_where=text("deleted_at IS NULL")), + ) + + # Relationships + venues: Mapped[list["Venue"]] = relationship(secondary="venue_amenities", back_populates="amenities") + + +class VenueAmenity(Base): + __tablename__ = "venue_amenities" + + venue_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("venues.id"), primary_key=True) + amenity_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("amenities.id"), primary_key=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + + +class VenueAvailability(Base): + __tablename__ = "venue_availability" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + venue_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("venues.id"), nullable=False) + day_of_week: Mapped[int] = mapped_column(Integer, nullable=False) + + is_available: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="true") + opens_at: Mapped[time | None] = mapped_column(Time, nullable=True) + closes_at: Mapped[time | None] = mapped_column(Time, nullable=True) + spans_next_day: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false") + + deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()) + + # Relationships + venue: Mapped["Venue"] = relationship(back_populates="availability") + + __table_args__ = ( + CheckConstraint("day_of_week BETWEEN 0 AND 6", name="ck_venue_availability_day"), + CheckConstraint("is_available = false OR (opens_at IS NOT NULL AND closes_at IS NOT NULL)", name="ck_venue_availability_times"), + Index("venue_availability_unique_day", "venue_id", "day_of_week", unique=True, postgresql_where=text("deleted_at IS NULL")), + ) + + +class VenueBlockedDate(Base): + __tablename__ = "venue_blocked_dates" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + venue_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("venues.id"), nullable=False) + starts_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + ends_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + reason: Mapped[str | None] = mapped_column(Text, nullable=True) + blocked_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("profiles.id"), nullable=False) + + deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + + # Relationships + venue: Mapped["Venue"] = relationship(back_populates="blocked_dates") + + __table_args__ = ( + CheckConstraint("ends_at > starts_at", name="ck_venue_blocked_dates_order"), + Index("idx_venue_blocked_dates_venue", "venue_id", postgresql_where=text("deleted_at IS NULL")), + ) + + +class VenuePricingRule(Base): + __tablename__ = "venue_pricing_rules" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + venue_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("venues.id", ondelete="CASCADE"), nullable=False) + + name: Mapped[str] = mapped_column(Text, nullable=False) + + days_of_week: Mapped[list[int] | None] = mapped_column(ARRAY(Integer), nullable=True) + start_date: Mapped[date | None] = mapped_column(Date, nullable=True) + end_date: Mapped[date | None] = mapped_column(Date, nullable=True) + start_time: Mapped[time | None] = mapped_column(Time, nullable=True) + end_time: Mapped[time | None] = mapped_column(Time, nullable=True) + + adjustment_type: Mapped[str] = mapped_column(Text, nullable=False) + multiplier: Mapped[float | None] = mapped_column(Numeric(5, 2), nullable=True) + amount_paise: Mapped[int | None] = mapped_column(BigInteger, nullable=True) + + applies_to: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'both'")) + priority: Mapped[int] = mapped_column(Integer, nullable=False, server_default="0") + source: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'owner'")) + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="true") + + deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()) + + # Relationships + venue: Mapped["Venue"] = relationship(back_populates="pricing_rules") + + __table_args__ = ( + CheckConstraint("adjustment_type IN ('multiplier', 'fixed_delta', 'override')", name="ck_venue_pricing_rules_adjustment_type"), + CheckConstraint( + "(adjustment_type = 'multiplier' AND multiplier IS NOT NULL AND multiplier > 0 AND amount_paise IS NULL) OR " + "(adjustment_type = 'fixed_delta' AND amount_paise IS NOT NULL AND multiplier IS NULL) OR " + "(adjustment_type = 'override' AND amount_paise IS NOT NULL AND amount_paise >= 0 AND multiplier IS NULL)", + name="ck_venue_pricing_rules_adjustment_payload", + ), + CheckConstraint("applies_to IN ('full_day', 'time_slot', 'both')", name="ck_venue_pricing_rules_applies_to"), + CheckConstraint("source IN ('owner', 'system')", name="ck_venue_pricing_rules_source"), + CheckConstraint("start_date IS NULL OR end_date IS NULL OR start_date <= end_date", name="ck_venue_pricing_rules_date_range"), + Index( + "idx_venue_pricing_rules_priority", + "venue_id", "priority", + postgresql_where=text("deleted_at IS NULL AND is_active = true"), + ), + ) + + +class VenueCancellationPolicy(Base): + __tablename__ = "venue_cancellation_policies" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + venue_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("venues.id"), nullable=False, unique=True) + + tier_1_hours: Mapped[int | None] = mapped_column(Integer, nullable=True) + tier_1_refund_pct: Mapped[float | None] = mapped_column(Numeric(5, 2), nullable=True) + tier_2_hours: Mapped[int | None] = mapped_column(Integer, nullable=True) + tier_2_refund_pct: Mapped[float | None] = mapped_column(Numeric(5, 2), nullable=True) + tier_3_hours: Mapped[int | None] = mapped_column(Integer, nullable=True) + tier_3_refund_pct: Mapped[float | None] = mapped_column(Numeric(5, 2), nullable=True) + + no_show_refund_pct: Mapped[float] = mapped_column(Numeric(5, 2), nullable=False, server_default="0") + platform_fee_refundable: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false") + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()) + + # Relationships + venue: Mapped["Venue"] = relationship(back_populates="cancellation_policy") + + __table_args__ = ( + CheckConstraint("tier_1_hours > 0", name="ck_vcp_tier_1_hours"), + CheckConstraint("tier_1_refund_pct BETWEEN 0 AND 100", name="ck_vcp_tier_1_pct"), + CheckConstraint("tier_2_hours > 0", name="ck_vcp_tier_2_hours"), + CheckConstraint("tier_2_refund_pct BETWEEN 0 AND 100", name="ck_vcp_tier_2_pct"), + CheckConstraint("tier_3_hours > 0", name="ck_vcp_tier_3_hours"), + CheckConstraint("tier_3_refund_pct BETWEEN 0 AND 100", name="ck_vcp_tier_3_pct"), + CheckConstraint("no_show_refund_pct BETWEEN 0 AND 100", name="ck_vcp_no_show_pct"), + CheckConstraint( + "(tier_1_hours IS NULL OR tier_2_hours IS NULL OR tier_1_hours > tier_2_hours) AND " + "(tier_2_hours IS NULL OR tier_3_hours IS NULL OR tier_2_hours > tier_3_hours)", + name="tiers_descending" + ), + CheckConstraint("(tier_1_hours IS NULL) = (tier_1_refund_pct IS NULL)", name="tier_1_paired"), + CheckConstraint("(tier_2_hours IS NULL) = (tier_2_refund_pct IS NULL)", name="tier_2_paired"), + CheckConstraint("(tier_3_hours IS NULL) = (tier_3_refund_pct IS NULL)", name="tier_3_paired"), + ) + + +class VenueLike(Base): + __tablename__ = "venue_likes" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("profiles.id", ondelete="CASCADE"), nullable=False) + venue_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("venues.id", ondelete="CASCADE"), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + + __table_args__ = ( + Index("uq_venue_likes_user_venue", "user_id", "venue_id", unique=True), + ) diff --git a/apps/api/app/modules/venue/pricing_engine.py b/apps/api/app/modules/venue/pricing_engine.py new file mode 100644 index 000000000..57c7b906b --- /dev/null +++ b/apps/api/app/modules/venue/pricing_engine.py @@ -0,0 +1,114 @@ +from dataclasses import dataclass +from datetime import date, time +from decimal import Decimal, ROUND_HALF_EVEN +from typing import Sequence +from uuid import UUID + +from app.modules.venue.models import VenuePricingRule + + +def _banker_round(value: Decimal) -> int: + """Banker's rounding to nearest integer for financial precision.""" + return int(value.quantize(Decimal("1"), rounding=ROUND_HALF_EVEN)) + + +@dataclass +class PricedUnit: + base_paise: int + final_paise: int + applied_rule_id: UUID | None + applied_rule_name: str | None + clamped: bool + + +def _rule_matches_date(rule: VenuePricingRule, target_date: date) -> bool: + if rule.days_of_week is not None and target_date.weekday() not in rule.days_of_week: + return False + if rule.start_date is not None and target_date < rule.start_date: + return False + if rule.end_date is not None and target_date > rule.end_date: + return False + return True + + +def _rule_matches_time(rule: VenuePricingRule, target_time: time | None) -> bool: + if rule.start_time is None and rule.end_time is None: + return True + if target_time is None: + return False + if rule.start_time is not None and target_time < rule.start_time: + return False + if rule.end_time is not None and target_time >= rule.end_time: + return False + return True + + +def resolve_rule( + rules: Sequence[VenuePricingRule], + *, + target_date: date, + target_time: time | None, + applies_to: str, +) -> VenuePricingRule | None: + """Highest-priority active rule matching the given date/time. No stacking.""" + candidates = [ + r for r in rules + if r.is_active + and r.deleted_at is None + and r.applies_to in (applies_to, "both") + and _rule_matches_date(r, target_date) + and _rule_matches_time(r, target_time) + ] + if not candidates: + return None + candidates.sort(key=lambda r: (r.priority, r.created_at), reverse=True) + return candidates[0] + + +def apply_adjustment(base_paise: int, rule: VenuePricingRule | None) -> int: + if rule is None: + return base_paise + if rule.adjustment_type == "multiplier": + return _banker_round(Decimal(str(base_paise)) * Decimal(str(rule.multiplier))) + if rule.adjustment_type == "fixed_delta": + return max(0, base_paise + rule.amount_paise) + if rule.adjustment_type == "override": + return rule.amount_paise + return base_paise + + +def clamp_price( + computed_paise: int, + base_paise: int, + min_price_pct: Decimal, + max_price_pct: Decimal, +) -> tuple[int, bool]: + floor_paise = _banker_round(Decimal(str(base_paise)) * min_price_pct / Decimal("100")) + ceiling_paise = _banker_round(Decimal(str(base_paise)) * max_price_pct / Decimal("100")) + if computed_paise < floor_paise: + return floor_paise, True + if computed_paise > ceiling_paise: + return ceiling_paise, True + return computed_paise, False + + +def price_unit( + rules: Sequence[VenuePricingRule], + *, + base_paise: int, + target_date: date, + target_time: time | None, + applies_to: str, + min_price_pct: Decimal, + max_price_pct: Decimal, +) -> PricedUnit: + rule = resolve_rule(rules, target_date=target_date, target_time=target_time, applies_to=applies_to) + computed_paise = apply_adjustment(base_paise, rule) + final_paise, clamped = clamp_price(computed_paise, base_paise, min_price_pct, max_price_pct) + return PricedUnit( + base_paise=base_paise, + final_paise=final_paise, + applied_rule_id=rule.id if rule else None, + applied_rule_name=rule.name if rule else None, + clamped=clamped, + ) diff --git a/apps/api/app/modules/venue/routes.py b/apps/api/app/modules/venue/routes.py new file mode 100644 index 000000000..175052528 --- /dev/null +++ b/apps/api/app/modules/venue/routes.py @@ -0,0 +1,361 @@ +from uuid import UUID +from datetime import datetime +from fastapi import APIRouter, Depends, Query, HTTPException, UploadFile, File +from sqlalchemy.orm import Session + +from app.core.database import get_db +from app.modules.auth.dependencies import require_owner, require_auth, get_current_user_optional, AuthContext +from app.modules.venue.schemas import ( + VenueResponse, + VenueListResponse, + VenueStatsResponse, + VenueCategoryResponse, + CreateVenueRequest, + UpdateVenueRequest, + PricingPreviewResponse, + DeleteResponse, + VenueAvailabilityResponse, + BulkUpdateAvailabilityRequest, + VenueBlockedDateResponse, + CreateBlockedDateRequest, + CancellationPolicyResponse, + UpdateCancellationPolicyRequest, + AmenityResponse, + UpdateVenueAmenitiesRequest, + BookingType, + PublicVenueBlockedDateResponse, + VenuePhotoResponse, + BulkUpdateVenuePhotosRequest, + VenuePricingRuleResponse, + CreatePricingRuleRequest, + UpdatePricingRuleRequest, +) +from app.modules.venue import service +from app.modules.booking import service as booking_service +from app.modules.booking.schemas import BookingOut +from app.shared.utils import parse_timezone_datetime + +router = APIRouter() + + + +# Owner routes + +@router.get( + "/my/venues", + response_model=list[VenueListResponse] +) +def list_my_venues( + auth: AuthContext = Depends(require_owner), + db: Session = Depends(get_db), +): + + return service.list_owner_venues(db, owner_id=auth.user_id) + + +@router.get("/my/venues/{venue_id}", response_model=VenueResponse) +def get_my_venue( + venue_id: UUID, + auth: AuthContext = Depends(require_owner), + db: Session = Depends(get_db), +): + return service.get_owner_venue(db, venue_id=venue_id, owner_id=auth.user_id) + + +@router.get("/my/venues/{venue_id}/stats", response_model=VenueStatsResponse) +def get_my_venue_stats( + venue_id: UUID, + auth: AuthContext = Depends(require_owner), + db: Session = Depends(get_db), +): + return service.get_venue_stats_this_month(db, venue_id=venue_id, owner_id=auth.user_id) + + +@router.post("/", response_model=VenueResponse, status_code=201) +def create_venue( + body: CreateVenueRequest, + auth: AuthContext = Depends(require_owner), + db: Session = Depends(get_db), +): + + return service.create_venue(db, owner_id=auth.user_id, body=body) + + +@router.patch("/{venue_id}", response_model=VenueResponse) +def update_venue( + venue_id: UUID, + body: UpdateVenueRequest, + auth: AuthContext = Depends(require_owner), + db: Session = Depends(get_db), +): + + return service.update_venue(db, venue_id=venue_id, owner_id=auth.user_id, body=body) + + +@router.delete("/{venue_id}", response_model=DeleteResponse, status_code=200) +def delete_venue( + venue_id: UUID, + auth: AuthContext = Depends(require_owner), + db: Session = Depends(get_db), +): + + service.delete_venue(db, venue_id=venue_id, owner_id=auth.user_id) + return DeleteResponse(id=venue_id) + + +@router.post("/{venue_id}/submit", response_model=VenueResponse) +def submit_venue( + venue_id: UUID, + auth: AuthContext = Depends(require_owner), + db: Session = Depends(get_db), +): + return service.submit_venue(db, venue_id=venue_id, owner_id=auth.user_id) + + +@router.put("/{venue_id}/availability", response_model=list[VenueAvailabilityResponse]) +def bulk_update_availability( + venue_id: UUID, + body: BulkUpdateAvailabilityRequest, + auth: AuthContext = Depends(require_owner), + db: Session = Depends(get_db), +): + return service.bulk_update_availability(db, venue_id, auth.user_id, body.availabilities) + + +@router.post("/{venue_id}/blocked-dates", response_model=VenueBlockedDateResponse, status_code=201) +def create_blocked_date( + venue_id: UUID, + body: CreateBlockedDateRequest, + auth: AuthContext = Depends(require_owner), + db: Session = Depends(get_db), +): + return service.create_blocked_date(db, venue_id, auth.user_id, body) + + +@router.delete("/{venue_id}/blocked-dates/{blocked_id}", response_model=DeleteResponse, status_code=200) +def delete_blocked_date( + venue_id: UUID, + blocked_id: UUID, + auth: AuthContext = Depends(require_owner), + db: Session = Depends(get_db), +): + service.delete_blocked_date(db, venue_id, blocked_id, auth.user_id) + return DeleteResponse(id=blocked_id, message="Blocked date removed successfully") + + +@router.get("/{venue_id}/pricing-rules", response_model=list[VenuePricingRuleResponse]) +def list_pricing_rules( + venue_id: UUID, + auth: AuthContext = Depends(require_owner), + db: Session = Depends(get_db), +): + return service.list_pricing_rules(db, venue_id, auth.user_id) + + +@router.post("/{venue_id}/pricing-rules", response_model=VenuePricingRuleResponse, status_code=201) +def create_pricing_rule( + venue_id: UUID, + body: CreatePricingRuleRequest, + auth: AuthContext = Depends(require_owner), + db: Session = Depends(get_db), +): + return service.create_pricing_rule(db, venue_id, auth.user_id, body) + + +@router.patch("/{venue_id}/pricing-rules/{rule_id}", response_model=VenuePricingRuleResponse) +def update_pricing_rule( + venue_id: UUID, + rule_id: UUID, + body: UpdatePricingRuleRequest, + auth: AuthContext = Depends(require_owner), + db: Session = Depends(get_db), +): + return service.update_pricing_rule(db, venue_id, rule_id, auth.user_id, body) + + +@router.delete("/{venue_id}/pricing-rules/{rule_id}", response_model=DeleteResponse, status_code=200) +def delete_pricing_rule( + venue_id: UUID, + rule_id: UUID, + auth: AuthContext = Depends(require_owner), + db: Session = Depends(get_db), +): + service.delete_pricing_rule(db, venue_id, rule_id, auth.user_id) + return DeleteResponse(id=rule_id, message="Pricing rule removed successfully") + + +@router.get("/{venue_id}/pricing-preview", response_model=PricingPreviewResponse) +def get_owner_pricing_preview( + venue_id: UUID, + starts_at: str = Query(..., description="ISO 8601 datetime with timezone offset"), + ends_at: str = Query(..., description="ISO 8601 datetime with timezone offset"), + booking_type: BookingType = Query(..., description="full_day or time_slot"), + auth: AuthContext = Depends(require_owner), + db: Session = Depends(get_db), +): + starts_dt = parse_timezone_datetime(starts_at, "starts_at") + ends_dt = parse_timezone_datetime(ends_at, "ends_at") + return service.get_owner_pricing_preview(db, venue_id, auth.user_id, starts_dt, ends_dt, booking_type) + + +@router.put("/{venue_id}/cancellation-policy", response_model=CancellationPolicyResponse) +def put_venue_cancellation_policy( + venue_id: UUID, + body: UpdateCancellationPolicyRequest, + auth: AuthContext = Depends(require_owner), + db: Session = Depends(get_db), +): + return service.put_venue_cancellation_policy(db, venue_id, auth.user_id, body) + + +@router.put("/{venue_id}/amenities", response_model=list[AmenityResponse]) +def update_venue_amenities( + venue_id: UUID, + body: UpdateVenueAmenitiesRequest, + auth: AuthContext = Depends(require_owner), + db: Session = Depends(get_db), +): + return service.update_venue_amenities(db, venue_id, auth.user_id, body) + + +@router.post("/{venue_id}/photos", response_model=VenuePhotoResponse, status_code=201) +async def add_venue_photo( + venue_id: UUID, + file: UploadFile = File(...), + auth: AuthContext = Depends(require_owner), + db: Session = Depends(get_db), +): + if not file.content_type.startswith("image/"): + raise HTTPException(status_code=400, detail="File must be an image") + + file_bytes = await file.read() + return service.add_venue_photo(db, venue_id, auth.user_id, file_bytes) + + +@router.put("/{venue_id}/photos/bulk-update", response_model=list[VenuePhotoResponse]) +def bulk_update_venue_photos( + venue_id: UUID, + body: BulkUpdateVenuePhotosRequest, + auth: AuthContext = Depends(require_owner), + db: Session = Depends(get_db), +): + return service.bulk_update_venue_photos(db, venue_id, auth.user_id, body) + + +@router.delete("/{venue_id}/photos/{photo_id}", response_model=DeleteResponse, status_code=200) +def delete_venue_photo( + venue_id: UUID, + photo_id: UUID, + auth: AuthContext = Depends(require_owner), + db: Session = Depends(get_db), +): + service.delete_venue_photo(db, venue_id, photo_id, auth.user_id) + return DeleteResponse(id=photo_id, message="Photo deleted successfully") + + +@router.get("/{venue_id}/bookings", response_model=list[BookingOut]) +def list_venue_bookings( + venue_id: UUID, + auth: AuthContext = Depends(require_owner), + db: Session = Depends(get_db), +): + return booking_service.list_venue_bookings(db, venue_id, auth.user_id) + + +@router.get("/{venue_id}/bookings/pending", response_model=list[BookingOut]) +def list_pending_venue_bookings( + venue_id: UUID, + auth: AuthContext = Depends(require_owner), + db: Session = Depends(get_db), +): + return booking_service.list_venue_bookings( + db, + venue_id, + auth.user_id, + pending_only=True, + ) + + + + + + + +# User routes + +@router.get("/likes", response_model=list[UUID]) +def get_liked_venue_ids( + auth: AuthContext = Depends(require_auth), + db: Session = Depends(get_db), +): + return service.get_liked_venue_ids(db, auth.user_id) + + +@router.post("/{venue_id}/like", response_model=dict) +def toggle_venue_like( + venue_id: UUID, + auth: AuthContext = Depends(require_auth), + db: Session = Depends(get_db), +): + is_liked = service.toggle_venue_like(db, venue_id, auth.user_id) + return {"is_liked": is_liked} + + + +# Public routes + +@router.get("/categories", response_model=list[VenueCategoryResponse]) +def get_venue_categories(db: Session = Depends(get_db)): + return service.get_venue_categories(db) + + +@router.get("/amenities", response_model=list[AmenityResponse]) +def get_platform_amenities(db: Session = Depends(get_db)): + return service.get_platform_amenities(db) + + +@router.get("/{identifier}", response_model=VenueResponse) +def get_venue( + identifier: str, + auth: AuthContext | None = Depends(get_current_user_optional), + db: Session = Depends(get_db), +): + + return service.get_venue(db, identifier, user_id=auth.user_id if auth else None) + + +@router.get("/{venue_id}/pricing", response_model=PricingPreviewResponse) +def get_pricing_preview( + venue_id: UUID, + starts_at: str = Query(..., description="ISO 8601 datetime with timezone offset"), + ends_at: str = Query(..., description="ISO 8601 datetime with timezone offset"), + booking_type: BookingType = Query(..., description="full_day or time_slot"), + db: Session = Depends(get_db), +): + starts_dt = parse_timezone_datetime(starts_at, "starts_at") + ends_dt = parse_timezone_datetime(ends_at, "ends_at") + return service.get_pricing_preview(db, venue_id, starts_dt, ends_dt, booking_type) + + +@router.get("/{venue_id}/availability", response_model=list[VenueAvailabilityResponse]) +def get_venue_availability( + venue_id: UUID, + db: Session = Depends(get_db), +): + return service.get_venue_availability(db, venue_id) + + +@router.get("/{venue_id}/blocked-dates", response_model=list[PublicVenueBlockedDateResponse]) +def get_venue_blocked_dates( + venue_id: UUID, + db: Session = Depends(get_db), +): + return service.get_venue_blocked_dates(db, venue_id) + + +@router.get("/{venue_id}/cancellation-policy", response_model=CancellationPolicyResponse) +def get_venue_cancellation_policy( + venue_id: UUID, + db: Session = Depends(get_db), +): + return service.get_venue_cancellation_policy(db, venue_id) diff --git a/apps/api/app/modules/venue/schemas.py b/apps/api/app/modules/venue/schemas.py new file mode 100644 index 000000000..852eb8a08 --- /dev/null +++ b/apps/api/app/modules/venue/schemas.py @@ -0,0 +1,703 @@ +from pydantic import BaseModel, field_validator, Field +from uuid import UUID +from datetime import datetime, date, time +from typing import Optional +from decimal import Decimal +from enum import Enum + + + +class BookingType(str, Enum): + full_day = "full_day" + time_slot = "time_slot" + + +class PricingMode(str, Enum): + flat = "flat" + hourly = "hourly" + mixed = "mixed" + + +class VenueStatus(str, Enum): + draft = "draft" + pending_approval = "pending_approval" + approved = "approved" + rejected = "rejected" + suspended = "suspended" + + +class BookingMode(str, Enum): + MANUAL = "MANUAL" + INSTANT = "INSTANT" + + + +class VenueCategoryResponse(BaseModel): + id: UUID + slug: str + label: str + icon: Optional[str] = None + banner_image: Optional[str] = None + is_active: bool + sort_order: int + + model_config = {"from_attributes": True} + + +class VenuePhotoResponse(BaseModel): + id: UUID + venue_id: UUID + image_url: str + sort_order: int + is_cover: bool + created_at: datetime + + model_config = {"from_attributes": True} + + +class AmenityResponse(BaseModel): + id: UUID + name: str + icon: Optional[str] = None + + model_config = {"from_attributes": True} + + +class CancellationPolicyResponse(BaseModel): + tier_1_hours: Optional[int] = None + tier_1_refund_pct: Optional[Decimal] = None + tier_2_hours: Optional[int] = None + tier_2_refund_pct: Optional[Decimal] = None + tier_3_hours: Optional[int] = None + tier_3_refund_pct: Optional[Decimal] = None + no_show_refund_pct: Decimal + platform_fee_refundable: bool + notes: Optional[str] = None + + model_config = {"from_attributes": True} + +class UpdateCancellationPolicyRequest(BaseModel): + tier_1_hours: Optional[int] = Field(default=None, gt=0) + tier_1_refund_pct: Optional[Decimal] = Field(default=None, ge=0, le=100) + tier_2_hours: Optional[int] = Field(default=None, gt=0) + tier_2_refund_pct: Optional[Decimal] = Field(default=None, ge=0, le=100) + tier_3_hours: Optional[int] = Field(default=None, gt=0) + tier_3_refund_pct: Optional[Decimal] = Field(default=None, ge=0, le=100) + no_show_refund_pct: Decimal = Field(default=Decimal("0.00"), ge=0, le=100) + platform_fee_refundable: bool = False + notes: Optional[str] = None + + def model_post_init(self, __context) -> None: + if (self.tier_1_hours is None) != (self.tier_1_refund_pct is None): + raise ValueError("tier_1_hours and tier_1_refund_pct must be both set or both null") + if (self.tier_2_hours is None) != (self.tier_2_refund_pct is None): + raise ValueError("tier_2_hours and tier_2_refund_pct must be both set or both null") + if (self.tier_3_hours is None) != (self.tier_3_refund_pct is None): + raise ValueError("tier_3_hours and tier_3_refund_pct must be both set or both null") + + if self.tier_1_hours is not None and self.tier_2_hours is not None: + if self.tier_1_hours <= self.tier_2_hours: + raise ValueError("tier_1_hours must be strictly greater than tier_2_hours") + if self.tier_2_hours is not None and self.tier_3_hours is not None: + if self.tier_2_hours <= self.tier_3_hours: + raise ValueError("tier_2_hours must be strictly greater than tier_3_hours") + + +class UpdateVenueAmenitiesRequest(BaseModel): + amenity_ids: list[UUID] + + +class UpdateVenuePhotoItem(BaseModel): + photo_id: UUID + sort_order: int + is_cover: bool + +class BulkUpdateVenuePhotosRequest(BaseModel): + photos: list[UpdateVenuePhotoItem] + + +from typing import Any +from pydantic import model_validator + +class VenueListResponse(BaseModel): + id: UUID + name: str + slug: Optional[str] = None + city: str + max_capacity: int + status: VenueStatus + is_active: bool + category_name: str + cover_photo_url: Optional[str] = None + last_completed_step: Optional[int] = Field(default=0, ge=0) + + @model_validator(mode="before") + @classmethod + def flatten_nested(cls, data: Any) -> Any: + if isinstance(data, dict): + return data + + result = { + "id": data.id, + "name": data.name, + "slug": data.slug, + "city": data.city, + "max_capacity": data.max_capacity, + "status": data.status, + "is_active": data.is_active, + "last_completed_step": data.last_completed_step, + } + + category = getattr(data, 'category', None) + result["category_name"] = category.label if category else "Uncategorized" + + photos = getattr(data, 'photos', []) + if photos: + cover = next((p for p in photos if p.is_cover), photos[0]) + result["cover_photo_url"] = cover.image_url + else: + result["cover_photo_url"] = None + + return result + +class VenueStatsResponse(BaseModel): + active_bookings: int + revenue_this_month_paise: int + +class VenueResponse(BaseModel): + id: UUID + owner_id: UUID + + + name: str + slug: Optional[str] = None + description: Optional[str] = None + category: VenueCategoryResponse + + + address_line1: str + address_line2: Optional[str] = None + city: str + state: str + country: str + postal_code: Optional[str] = None + latitude: Optional[Decimal] = None + longitude: Optional[Decimal] = None + timezone: str + + + min_capacity: Optional[int] = None + max_capacity: int + + + open_time: time + close_time: time + spans_next_day: bool + + + allowed_booking_types: list[BookingType] + min_booking_duration_minutes: int + max_booking_duration_minutes: int + slot_interval_minutes: int + + + pre_buffer_minutes: int + post_buffer_minutes: int + + + pricing_mode: PricingMode + starting_price_paise: Optional[int] = None + hourly_rate_paise: Optional[int] = None + + + platform_commission_pct: Decimal + + + advance_pct: Decimal + balance_due_days_before_event: int + owner_action_window_hours: int + overdue_advance_refund_pct: Decimal + + min_price_pct: Decimal + max_price_pct: Decimal + display_price_min_paise: Optional[int] = None + display_price_max_paise: Optional[int] = None + + + status: VenueStatus + booking_mode: BookingMode = BookingMode.MANUAL + is_active: bool + + + created_at: datetime + updated_at: datetime + + last_completed_step: int + + + photos: list[VenuePhotoResponse] = Field(default_factory=list) + amenities: list[AmenityResponse] = Field(default_factory=list) + cancellation_policy: Optional[CancellationPolicyResponse] = None + is_liked: bool = False + + model_config = {"from_attributes": True} + + +class DeleteResponse(BaseModel): + id: UUID + deleted: bool = True + message: str = "Venue deleted successfully" + + +class CreateVenueRequest(BaseModel): + + name: str + description: Optional[str] = None + category_id: UUID + + + address_line1: str + address_line2: Optional[str] = None + city: str + state: str + country: str = "India" + postal_code: Optional[str] = None + latitude: Optional[Decimal] = None + longitude: Optional[Decimal] = None + timezone: str = "Asia/Kolkata" + + + min_capacity: Optional[int] = Field(default=None, gt=0) + max_capacity: int = Field(gt=0) + + + open_time: time + close_time: time + spans_next_day: bool = False + + + allowed_booking_types: list[BookingType] = Field(default_factory=lambda: [BookingType.full_day, BookingType.time_slot]) + min_booking_duration_minutes: int = Field(default=60, gt=0) + max_booking_duration_minutes: int = Field(default=1440, gt=0) + slot_interval_minutes: int = Field(default=30, gt=0) + + + pre_buffer_minutes: int = Field(default=0, ge=0) + post_buffer_minutes: int = Field(default=0, ge=0) + + + pricing_mode: PricingMode = PricingMode.flat + starting_price_paise: Optional[int] = Field(default=None, ge=0) + hourly_rate_paise: Optional[int] = Field(default=None, ge=0) + + + advance_pct: Decimal = Field(default=Decimal("30.00"), gt=0, le=100) + balance_due_days_before_event: int = Field(default=7, gt=0) + owner_action_window_hours: int = Field(default=48, ge=24, le=72) + overdue_advance_refund_pct: Decimal = Field(default=Decimal("0.00"), ge=0, le=100) + + min_price_pct: Decimal = Field(default=Decimal("50.00"), gt=0, le=100) + max_price_pct: Decimal = Field(default=Decimal("200.00"), ge=100, le=500) + + cancellation_policy: Optional[UpdateCancellationPolicyRequest] = None + amenity_ids: Optional[list[UUID]] = None + + last_completed_step: Optional[int] = Field(default=0, ge=0) + + @field_validator("allowed_booking_types") + @classmethod + def validate_booking_types(cls, v: list[BookingType]) -> list[BookingType]: + if not v: + raise ValueError("allowed_booking_types cannot be empty") + if len(v) != len(set(v)): + raise ValueError("Duplicate booking types are not allowed") + return v + + def model_post_init(self, __context) -> None: + has_full_day = BookingType.full_day in self.allowed_booking_types + has_time_slot = BookingType.time_slot in self.allowed_booking_types + + if has_full_day and has_time_slot: + if self.pricing_mode != PricingMode.mixed: + raise ValueError("pricing_mode must be 'mixed' when both full_day and time_slot are allowed") + elif has_full_day: + if self.pricing_mode != PricingMode.flat: + raise ValueError("pricing_mode must be 'flat' when only full_day is allowed") + elif has_time_slot: + if self.pricing_mode != PricingMode.hourly: + raise ValueError("pricing_mode must be 'hourly' when only time_slot is allowed") + + if self.pricing_mode == PricingMode.flat: + if self.starting_price_paise is None: + raise ValueError("starting_price_paise is required when pricing_mode is 'flat'") + if self.hourly_rate_paise is not None: + raise ValueError("hourly_rate_paise must be null when pricing_mode is 'flat'") + elif self.pricing_mode == PricingMode.hourly: + if self.hourly_rate_paise is None: + raise ValueError("hourly_rate_paise is required when pricing_mode is 'hourly'") + if self.starting_price_paise is not None: + raise ValueError("starting_price_paise must be null when pricing_mode is 'hourly'") + elif self.pricing_mode == PricingMode.mixed: + if self.starting_price_paise is None or self.hourly_rate_paise is None: + raise ValueError("Both starting_price_paise and hourly_rate_paise are required when pricing_mode is 'mixed'") + + + if ( + self.min_capacity is not None + and self.min_capacity > self.max_capacity + ): + raise ValueError("min_capacity cannot exceed max_capacity") + + + if self.min_booking_duration_minutes > self.max_booking_duration_minutes: + raise ValueError( + "min_booking_duration_minutes cannot exceed max_booking_duration_minutes" + ) + + if not self.spans_next_day and self.close_time <= self.open_time: + raise ValueError("close_time must be after open_time unless spans_next_day is true") + + if self.min_price_pct > self.max_price_pct: + raise ValueError("min_price_pct cannot exceed max_price_pct") + + +class UpdateVenueRequest(BaseModel): + name: Optional[str] = None + description: Optional[str] = None + category_id: Optional[UUID] = None + + address_line1: Optional[str] = None + address_line2: Optional[str] = None + city: Optional[str] = None + state: Optional[str] = None + country: Optional[str] = None + postal_code: Optional[str] = None + latitude: Optional[Decimal] = None + longitude: Optional[Decimal] = None + timezone: Optional[str] = None + + min_capacity: Optional[int] = Field(default=None, gt=0) + max_capacity: Optional[int] = Field(default=None, gt=0) + + open_time: Optional[time] = None + close_time: Optional[time] = None + spans_next_day: Optional[bool] = None + + allowed_booking_types: Optional[list[BookingType]] = None + min_booking_duration_minutes: Optional[int] = Field(default=None, gt=0) + max_booking_duration_minutes: Optional[int] = Field(default=None, gt=0) + slot_interval_minutes: Optional[int] = Field(default=None, gt=0) + + pre_buffer_minutes: Optional[int] = Field(default=None, ge=0) + post_buffer_minutes: Optional[int] = Field(default=None, ge=0) + + pricing_mode: Optional[PricingMode] = None + starting_price_paise: Optional[int] = Field(default=None, ge=0) + hourly_rate_paise: Optional[int] = Field(default=None, ge=0) + + advance_pct: Optional[Decimal] = Field(default=None, gt=0, le=100) + balance_due_days_before_event: Optional[int] = Field(default=None, gt=0) + owner_action_window_hours: Optional[int] = Field(default=None, ge=24, le=72) + overdue_advance_refund_pct: Optional[Decimal] = Field(default=None, ge=0, le=100) + + min_price_pct: Optional[Decimal] = Field(default=None, gt=0, le=100) + max_price_pct: Optional[Decimal] = Field(default=None, ge=100, le=500) + + last_completed_step: Optional[int] = None + + @field_validator("allowed_booking_types") + @classmethod + def validate_booking_types(cls, v: Optional[list[BookingType]]) -> Optional[list[BookingType]]: + if v is not None: + if not v: + raise ValueError("allowed_booking_types cannot be empty if provided") + if len(v) != len(set(v)): + raise ValueError("Duplicate booking types are not allowed") + return v + + def model_post_init(self, __context) -> None: + if self.allowed_booking_types is not None and self.pricing_mode is not None: + has_full_day = BookingType.full_day in self.allowed_booking_types + has_time_slot = BookingType.time_slot in self.allowed_booking_types + + if has_full_day and has_time_slot: + if self.pricing_mode != PricingMode.mixed: + raise ValueError("pricing_mode must be 'mixed' when both full_day and time_slot are allowed") + elif has_full_day: + if self.pricing_mode != PricingMode.flat: + raise ValueError("pricing_mode must be 'flat' when only full_day is allowed") + elif has_time_slot: + if self.pricing_mode != PricingMode.hourly: + raise ValueError("pricing_mode must be 'hourly' when only time_slot is allowed") + + if self.pricing_mode == PricingMode.flat and self.hourly_rate_paise is not None: + raise ValueError("hourly_rate_paise must be null when pricing_mode is 'flat'") + if self.pricing_mode == PricingMode.hourly and self.starting_price_paise is not None: + raise ValueError("starting_price_paise must be null when pricing_mode is 'hourly'") + + if ( + self.min_capacity is not None + and self.max_capacity is not None + and self.min_capacity > self.max_capacity + ): + raise ValueError("min_capacity cannot exceed max_capacity") + + if ( + self.min_booking_duration_minutes is not None + and self.max_booking_duration_minutes is not None + and self.min_booking_duration_minutes > self.max_booking_duration_minutes + ): + raise ValueError( + "min_booking_duration_minutes cannot exceed max_booking_duration_minutes" + ) + + if self.open_time is not None and self.close_time is not None: + spans_next = self.spans_next_day if self.spans_next_day is not None else False + if not spans_next and self.close_time <= self.open_time: + raise ValueError("close_time must be after open_time unless spans_next_day is true") + + if ( + self.min_price_pct is not None + and self.max_price_pct is not None + and self.min_price_pct > self.max_price_pct + ): + raise ValueError("min_price_pct cannot exceed max_price_pct") + + +class PricingDisplay(BaseModel): + quoted_price: str + advance_due: str + balance_due: str + platform_fee: str + owner_payout: str + + +class PricingBreakdownItem(BaseModel): + period_date: date + start_time: Optional[time] = None + end_time: Optional[time] = None + base_paise: int + applied_rule_id: Optional[UUID] = None + applied_rule_name: Optional[str] = None + clamped: bool + final_paise: int + + +class PricingPreviewResponse(BaseModel): + pricing_mode: PricingMode + quoted_price_paise: int + platform_commission_pct: float + platform_fee_paise: int + owner_payout_paise: int + advance_pct: float + advance_due_paise: int + balance_due_paise: int + display: PricingDisplay + breakdown: list[PricingBreakdownItem] = Field(default_factory=list) + clamped: bool = False + + +# ─── Search Result (used by search module) ──────────────────────────────────── + +class VenueSearchResult(BaseModel): + id: UUID + name: str + slug: Optional[str] = None + category: VenueCategoryResponse + city: str + state: str + max_capacity: int + pricing_mode: PricingMode + starting_price_paise: Optional[int] = None + hourly_rate_paise: Optional[int] = None + cover_photo_url: Optional[str] = None + status: VenueStatus + is_liked: bool = False + + model_config = {"from_attributes": True} + + +class VenueAvailabilityResponse(BaseModel): + day_of_week: int = Field(ge=0, le=6) + is_available: bool + opens_at: Optional[time] = None + closes_at: Optional[time] = None + spans_next_day: bool + + model_config = {"from_attributes": True} + +class VenueAvailabilityUpdate(BaseModel): + day_of_week: int = Field(ge=0, le=6) + is_available: bool + opens_at: Optional[time] = None + closes_at: Optional[time] = None + spans_next_day: bool = False + + def model_post_init(self, __context) -> None: + if self.is_available: + if self.opens_at is None or self.closes_at is None: + raise ValueError("opens_at and closes_at are required when is_available is true") + if not self.spans_next_day and self.closes_at <= self.opens_at: + raise ValueError("closes_at must be after opens_at unless spans_next_day is true") + +class BulkUpdateAvailabilityRequest(BaseModel): + availabilities: list[VenueAvailabilityUpdate] + + @field_validator("availabilities") + @classmethod + def validate_unique_days(cls, v: list[VenueAvailabilityUpdate]) -> list[VenueAvailabilityUpdate]: + days = [item.day_of_week for item in v] + if len(days) != len(set(days)): + raise ValueError("Duplicate day_of_week entries are not allowed") + return v + + +class PublicVenueBlockedDateResponse(BaseModel): + id: UUID + venue_id: UUID + starts_at: datetime + ends_at: datetime + + model_config = {"from_attributes": True} + + +class VenueBlockedDateResponse(BaseModel): + id: UUID + venue_id: UUID + starts_at: datetime + ends_at: datetime + reason: Optional[str] = None + blocked_by: UUID + created_at: datetime + + model_config = {"from_attributes": True} + +class CreateBlockedDateRequest(BaseModel): + starts_at: datetime + ends_at: datetime + reason: Optional[str] = None + + def model_post_init(self, __context) -> None: + if self.ends_at <= self.starts_at: + raise ValueError("ends_at must be strictly after starts_at") + + +class PricingRuleAdjustmentType(str, Enum): + multiplier = "multiplier" + fixed_delta = "fixed_delta" + override = "override" + + +class PricingRuleAppliesTo(str, Enum): + full_day = "full_day" + time_slot = "time_slot" + both = "both" + + +MAX_ACTIVE_PRICING_RULES_PER_VENUE = 20 + +MIN_VENUE_PHOTOS = 3 + + +class VenuePricingRuleResponse(BaseModel): + id: UUID + venue_id: UUID + name: str + days_of_week: Optional[list[int]] = None + start_date: Optional[date] = None + end_date: Optional[date] = None + start_time: Optional[time] = None + end_time: Optional[time] = None + adjustment_type: PricingRuleAdjustmentType + multiplier: Optional[Decimal] = None + amount_paise: Optional[int] = None + applies_to: PricingRuleAppliesTo + priority: int + source: str + is_active: bool + created_at: datetime + updated_at: datetime + exceeds_bounds: bool = False + + model_config = {"from_attributes": True} + + +class CreatePricingRuleRequest(BaseModel): + name: str = Field(min_length=1) + days_of_week: Optional[list[int]] = None + start_date: Optional[date] = None + end_date: Optional[date] = None + start_time: Optional[time] = None + end_time: Optional[time] = None + adjustment_type: PricingRuleAdjustmentType = PricingRuleAdjustmentType.multiplier + multiplier: Optional[Decimal] = Field(default=None, gt=0) + amount_paise: Optional[int] = None + applies_to: PricingRuleAppliesTo = PricingRuleAppliesTo.both + priority: int = 0 + is_active: bool = True + + def model_post_init(self, __context) -> None: + if self.days_of_week is not None: + if not self.days_of_week: + raise ValueError("days_of_week cannot be empty if provided") + if any(d < 0 or d > 6 for d in self.days_of_week): + raise ValueError("days_of_week values must be between 0 (Mon) and 6 (Sun)") + + if self.start_date is not None and self.end_date is not None and self.start_date > self.end_date: + raise ValueError("start_date cannot be after end_date") + + if self.adjustment_type == PricingRuleAdjustmentType.multiplier: + if self.multiplier is None: + raise ValueError("multiplier is required when adjustment_type is 'multiplier'") + if self.amount_paise is not None: + raise ValueError("amount_paise must be null when adjustment_type is 'multiplier'") + else: + if self.amount_paise is None: + raise ValueError("amount_paise is required when adjustment_type is 'fixed_delta' or 'override'") + if self.multiplier is not None: + raise ValueError("multiplier must be null when adjustment_type is not 'multiplier'") + if self.adjustment_type == PricingRuleAdjustmentType.override and self.amount_paise < 0: + raise ValueError("amount_paise must be >= 0 when adjustment_type is 'override'") + + +class UpdatePricingRuleRequest(BaseModel): + name: Optional[str] = Field(default=None, min_length=1) + days_of_week: Optional[list[int]] = None + start_date: Optional[date] = None + end_date: Optional[date] = None + start_time: Optional[time] = None + end_time: Optional[time] = None + adjustment_type: Optional[PricingRuleAdjustmentType] = None + multiplier: Optional[Decimal] = Field(default=None, gt=0) + amount_paise: Optional[int] = None + applies_to: Optional[PricingRuleAppliesTo] = None + priority: Optional[int] = None + is_active: Optional[bool] = None + + def model_post_init(self, __context) -> None: + if self.days_of_week is not None: + if not self.days_of_week: + raise ValueError("days_of_week cannot be empty if provided") + if any(d < 0 or d > 6 for d in self.days_of_week): + raise ValueError("days_of_week values must be between 0 (Mon) and 6 (Sun)") + + if self.start_date is not None and self.end_date is not None and self.start_date > self.end_date: + raise ValueError("start_date cannot be after end_date") + + +class PricingQuote(BaseModel): + quoted_price_paise: int + + platform_commission_pct: float + platform_fee_paise: int + + owner_payout_paise: int + + advance_pct: float + advance_due_paise: int + balance_due_paise: int + + pricing_mode: str + + breakdown: list[PricingBreakdownItem] = Field(default_factory=list) + clamped: bool = False diff --git a/apps/api/app/modules/venue/service.py b/apps/api/app/modules/venue/service.py new file mode 100644 index 000000000..069c9677f --- /dev/null +++ b/apps/api/app/modules/venue/service.py @@ -0,0 +1,1166 @@ +import re +import uuid +from datetime import datetime, timedelta, timezone +from decimal import Decimal, ROUND_HALF_EVEN +from uuid import UUID + +from sqlalchemy.orm import Session, joinedload, selectinload + +from app.core.exceptions import NotFoundError, ForbiddenError, ConflictError +from app.modules.venue.models import Venue, VenueCategory, VenueStatus, VenueAvailability, VenueBlockedDate, VenueCancellationPolicy, VenueAmenity, Amenity, VenuePhoto, VenuePricingRule, VenueLike +from app.modules.venue.schemas import ( + CreateVenueRequest, + UpdateVenueRequest, + PricingPreviewResponse, + PricingDisplay, + PricingBreakdownItem, + PricingQuote, + VenueAvailabilityUpdate, + CreateBlockedDateRequest, + UpdateCancellationPolicyRequest, + UpdateVenueAmenitiesRequest, + BulkUpdateVenuePhotosRequest, + VenuePricingRuleResponse, + CreatePricingRuleRequest, + UpdatePricingRuleRequest, + MAX_ACTIVE_PRICING_RULES_PER_VENUE, + MIN_VENUE_PHOTOS, +) +from app.modules.venue import pricing_engine +from app.modules.booking.models import BookingType, Booking, BookingStatus +from app.modules.payment.models import LedgerEntry +from sqlalchemy import func +from app.core.storage import upload_image_to_cloudinary, delete_image_from_cloudinary + + +# Default platform commission + +DEFAULT_PLATFORM_COMMISSION_PCT = Decimal("10.00") + + +# Internal helpers + +def _get_venue_or_404(db: Session, venue_id: str | UUID) -> Venue: + try: + vid = UUID(str(venue_id)) + venue = db.query(Venue).filter( + Venue.id == vid, + Venue.deleted_at.is_(None), + ).first() + except ValueError: + venue = db.query(Venue).filter( + Venue.slug == str(venue_id), + Venue.deleted_at.is_(None), + ).first() + + if not venue: + raise NotFoundError("Venue not found") + return venue + +# Acquire exclusive write lock on Venue to serialize slot check and creation +def _get_active_venue_or_404( + db: Session, + venue_id: str | UUID, + *, + for_update: bool = False, +) -> Venue: + query = db.query(Venue).filter( + Venue.status == VenueStatus.approved, + Venue.is_active.is_(True), + Venue.deleted_at.is_(None), + ) + + try: + vid = UUID(str(venue_id)) + query = query.filter(Venue.id == vid) + except ValueError: + query = query.filter(Venue.slug == str(venue_id)) + + if for_update: + query = query.with_for_update() + + venue = query.first() + + if not venue: + raise NotFoundError("Venue not found") + + return venue + + +def _assert_owner(venue: Venue, owner_id: UUID) -> None: + if venue.owner_id != owner_id: + raise ForbiddenError("You do not own this venue") + + +def _banker_round(value: Decimal) -> int: + """Banker's rounding to nearest integer for financial precision.""" + return int(value.quantize(Decimal('1'), rounding=ROUND_HALF_EVEN)) + + +def _format_inr(paise: int) -> str: + rupees = paise / 100 + return f"₹{rupees:,.0f}" + + +def _generate_slug(db: Session, name: str) -> str: + base_slug = re.sub(r'[^a-z0-9]+', '-', name.lower()).strip('-') + if not base_slug: + base_slug = "venue" + + slug = base_slug + counter = 1 + while db.query(Venue).filter(Venue.slug == slug).first(): + slug = f"{base_slug}-{counter}" + counter += 1 + return slug + + +# Public service functions + +def get_venue_categories(db: Session) -> list[VenueCategory]: + return ( + db.query(VenueCategory) + .filter(VenueCategory.is_active.is_(True), VenueCategory.deleted_at.is_(None)) + .order_by(VenueCategory.sort_order.asc(), VenueCategory.label.asc()) + .all() + ) + + +def get_platform_amenities(db: Session) -> list[Amenity]: + return db.query(Amenity).filter(Amenity.deleted_at.is_(None)).order_by(Amenity.name.asc()).all() + +def get_venue(db: Session, identifier: str, user_id: UUID | None = None) -> Venue: + try: + venue_id = UUID(identifier) + venue = _get_active_venue_or_404(db, venue_id) + except ValueError: + venue = db.query(Venue).filter( + Venue.slug == identifier, + Venue.status == VenueStatus.approved, + Venue.is_active.is_(True), + Venue.deleted_at.is_(None), + ).first() + if not venue: + raise NotFoundError("Venue not found") + return venue + + +def toggle_venue_like(db: Session, venue_id: UUID, user_id: UUID) -> bool: + venue = _get_active_venue_or_404(db, venue_id) + like = db.query(VenueLike).filter(VenueLike.user_id == user_id, VenueLike.venue_id == venue.id).first() + + if like: + db.delete(like) + db.commit() + return False + else: + new_like = VenueLike(user_id=user_id, venue_id=venue.id) + db.add(new_like) + db.commit() + return True + + +def get_liked_venue_ids(db: Session, user_id: UUID) -> list[UUID]: + likes = db.query(VenueLike).filter(VenueLike.user_id == user_id).all() + return [like.venue_id for like in likes] + + +def get_pricing_preview( + db: Session, + venue_id: UUID, + starts_at: datetime, + ends_at: datetime, + booking_type: BookingType, +) -> PricingPreviewResponse: + venue = _get_active_venue_or_404(db, venue_id) + return _compute_pricing_preview(db, venue, starts_at, ends_at, booking_type) + + +def get_owner_pricing_preview( + db: Session, + venue_id: UUID, + owner_id: UUID, + starts_at: datetime, + ends_at: datetime, + booking_type: BookingType, +) -> PricingPreviewResponse: + """Owner-facing dry run: same engine as the public quote, but usable on + draft/pending venues too (ownership-gated instead of approval-gated).""" + venue = _get_venue_or_404(db, venue_id) + _assert_owner(venue, owner_id) + return _compute_pricing_preview(db, venue, starts_at, ends_at, booking_type) + + +def _compute_pricing_preview( + db: Session, + venue: Venue, + starts_at: datetime, + ends_at: datetime, + booking_type: BookingType, +) -> PricingPreviewResponse: + if ends_at <= starts_at: + raise ConflictError("ends_at must be after starts_at") + + active_rules = [ + r for r in db.query(VenuePricingRule).filter( + VenuePricingRule.venue_id == venue.id, + VenuePricingRule.deleted_at.is_(None), + VenuePricingRule.is_active.is_(True), + ).all() + ] + applies_to = "full_day" if booking_type == BookingType.full_day else "time_slot" + has_matching_rules = any(r.applies_to in (applies_to, "both") for r in active_rules) + + breakdown: list[PricingBreakdownItem] = [] + any_clamped = False + + # ── Pricing Logic ───────────────────────────────────── + if booking_type == BookingType.full_day: + base = venue.starting_price_paise or 0 + + if not has_matching_rules: + days = (ends_at.date() - starts_at.date()).days + 1 + quoted_price_paise = base * days + else: + quoted_price_paise = 0 + current_date = starts_at.date() + end_date = ends_at.date() + while current_date <= end_date: + unit = pricing_engine.price_unit( + active_rules, + base_paise=base, + target_date=current_date, + target_time=None, + applies_to="full_day", + min_price_pct=Decimal(str(venue.min_price_pct)), + max_price_pct=Decimal(str(venue.max_price_pct)), + ) + quoted_price_paise += unit.final_paise + any_clamped = any_clamped or unit.clamped + breakdown.append(PricingBreakdownItem( + period_date=current_date, + base_paise=unit.base_paise, + applied_rule_id=unit.applied_rule_id, + applied_rule_name=unit.applied_rule_name, + clamped=unit.clamped, + final_paise=unit.final_paise, + )) + current_date += timedelta(days=1) + used_mode = "flat" # full_day always uses base price (per day) + + elif booking_type == BookingType.time_slot: + hourly_rate = venue.hourly_rate_paise or 0 + + if not has_matching_rules: + duration_seconds = (ends_at - starts_at).total_seconds() + duration_hours = Decimal(str(duration_seconds)) / Decimal("3600") + quoted_price_paise = _banker_round(Decimal(str(hourly_rate)) * duration_hours) + else: + interval_minutes = venue.slot_interval_minutes or 30 + quoted_price_paise = 0 + cursor = starts_at + while cursor < ends_at: + segment_end = min(cursor + timedelta(minutes=interval_minutes), ends_at) + segment_hours = Decimal(str((segment_end - cursor).total_seconds())) / Decimal("3600") + segment_base = _banker_round(Decimal(str(hourly_rate)) * segment_hours) + unit = pricing_engine.price_unit( + active_rules, + base_paise=segment_base, + target_date=cursor.date(), + target_time=cursor.time(), + applies_to="time_slot", + min_price_pct=Decimal(str(venue.min_price_pct)), + max_price_pct=Decimal(str(venue.max_price_pct)), + ) + quoted_price_paise += unit.final_paise + any_clamped = any_clamped or unit.clamped + breakdown.append(PricingBreakdownItem( + period_date=cursor.date(), + start_time=cursor.time(), + end_time=segment_end.time(), + base_paise=unit.base_paise, + applied_rule_id=unit.applied_rule_id, + applied_rule_name=unit.applied_rule_name, + clamped=unit.clamped, + final_paise=unit.final_paise, + )) + cursor = segment_end + used_mode = "hourly" + + else: + raise ConflictError(f"Invalid booking_type: {booking_type}") + + platform_fee_paise = _banker_round( + Decimal(str(quoted_price_paise)) + * Decimal(str(venue.platform_commission_pct)) + / Decimal("100") + ) + + owner_payout_paise = quoted_price_paise - platform_fee_paise + + advance_due_paise = _banker_round( + Decimal(str(quoted_price_paise)) + * Decimal(str(venue.advance_pct)) + / Decimal("100") + ) + + balance_due_paise = quoted_price_paise - advance_due_paise + + return PricingPreviewResponse( + pricing_mode=used_mode, + quoted_price_paise=quoted_price_paise, + platform_commission_pct=float(venue.platform_commission_pct), + platform_fee_paise=platform_fee_paise, + owner_payout_paise=owner_payout_paise, + advance_pct=float(venue.advance_pct), + advance_due_paise=advance_due_paise, + balance_due_paise=balance_due_paise, + display=PricingDisplay( + quoted_price=_format_inr(quoted_price_paise), + advance_due=_format_inr(advance_due_paise), + balance_due=_format_inr(balance_due_paise), + platform_fee=_format_inr(platform_fee_paise), + owner_payout=_format_inr(owner_payout_paise), + ), + breakdown=breakdown, + clamped=any_clamped, + ) + + +# Owner service functions + +def list_owner_venues(db: Session, owner_id: UUID) -> list[Venue]: + + return ( + db.query(Venue) + .options( + joinedload(Venue.category), + selectinload(Venue.photos), + selectinload(Venue.amenities), + joinedload(Venue.cancellation_policy) + ) + .filter( + Venue.owner_id == owner_id, + Venue.deleted_at.is_(None), + ) + .order_by(Venue.created_at.desc()) + .all() + ) + + +def get_owner_venue(db: Session, venue_id: UUID, owner_id: UUID) -> Venue: + venue = ( + db.query(Venue) + .options( + joinedload(Venue.category), + selectinload(Venue.photos), + selectinload(Venue.amenities), + joinedload(Venue.cancellation_policy) + ) + .filter( + Venue.id == venue_id, + Venue.owner_id == owner_id, + Venue.deleted_at.is_(None), + ) + .first() + ) + if not venue: + raise NotFoundError("Venue not found") + return venue + + +def _get_category_or_400(db: Session, category_id: UUID) -> VenueCategory: + cat = db.query(VenueCategory).filter( + VenueCategory.id == category_id, + VenueCategory.is_active.is_(True), + VenueCategory.deleted_at.is_(None), + ).first() + if not cat: + raise ConflictError("Invalid or inactive category") + return cat + + +def create_venue(db: Session, owner_id: UUID, body: CreateVenueRequest) -> Venue: + + _get_category_or_400(db, body.category_id) + + venue = Venue( + id=uuid.uuid4(), + owner_id=owner_id, + + name=body.name, + slug=_generate_slug(db, body.name), + description=body.description, + category_id=body.category_id, + + + address_line1=body.address_line1, + address_line2=body.address_line2, + city=body.city, + state=body.state, + country=body.country, + postal_code=body.postal_code, + latitude=body.latitude, + longitude=body.longitude, + timezone=body.timezone, + + + min_capacity=body.min_capacity, + max_capacity=body.max_capacity, + + + open_time=body.open_time, + close_time=body.close_time, + spans_next_day=body.spans_next_day, + + + allowed_booking_types=[bt.value for bt in body.allowed_booking_types], + min_booking_duration_minutes=body.min_booking_duration_minutes, + max_booking_duration_minutes=body.max_booking_duration_minutes, + slot_interval_minutes=body.slot_interval_minutes, + + + pre_buffer_minutes=body.pre_buffer_minutes, + post_buffer_minutes=body.post_buffer_minutes, + + + pricing_mode=body.pricing_mode.value, + starting_price_paise=body.starting_price_paise, + hourly_rate_paise=body.hourly_rate_paise, + + + platform_commission_pct=DEFAULT_PLATFORM_COMMISSION_PCT, + + + advance_pct=body.advance_pct, + balance_due_days_before_event=body.balance_due_days_before_event, + owner_action_window_hours=body.owner_action_window_hours, + overdue_advance_refund_pct=body.overdue_advance_refund_pct, + + min_price_pct=body.min_price_pct, + max_price_pct=body.max_price_pct, + + + status=VenueStatus.draft, + + last_completed_step=body.last_completed_step, + + is_active=True, + ) + + db.add(venue) + db.flush() + + if body.cancellation_policy: + policy = VenueCancellationPolicy( + id=uuid.uuid4(), + venue_id=venue.id, + tier_1_hours=body.cancellation_policy.tier_1_hours, + tier_1_refund_pct=body.cancellation_policy.tier_1_refund_pct, + tier_2_hours=body.cancellation_policy.tier_2_hours, + tier_2_refund_pct=body.cancellation_policy.tier_2_refund_pct, + tier_3_hours=body.cancellation_policy.tier_3_hours, + tier_3_refund_pct=body.cancellation_policy.tier_3_refund_pct, + no_show_refund_pct=body.cancellation_policy.no_show_refund_pct, + platform_fee_refundable=body.cancellation_policy.platform_fee_refundable, + notes=body.cancellation_policy.notes, + ) + db.add(policy) + + if body.amenity_ids: + valid_amenities = db.query(Amenity).filter( + Amenity.id.in_(body.amenity_ids), + Amenity.deleted_at.is_(None), + ).all() + if len(valid_amenities) != len(set(body.amenity_ids)): + raise ConflictError("One or more amenity IDs provided do not exist in the platform.") + + new_links = [VenueAmenity(venue_id=venue.id, amenity_id=am_id) for am_id in set(body.amenity_ids)] + db.add_all(new_links) + + db.commit() + db.refresh(venue) + + from app.modules.search.indexer import enqueue_job + enqueue_job(db, venue.id, "create") + + return venue + + +def update_venue( + db: Session, + venue_id: UUID, + owner_id: UUID, + body: UpdateVenueRequest, +) -> Venue: + + venue = _get_venue_or_404(db, venue_id) + _assert_owner(venue, owner_id) + + update_data = body.model_dump(exclude_unset=True) + + if "category_id" in update_data and update_data["category_id"] is not None: + _get_category_or_400(db, update_data["category_id"]) + + for field, value in update_data.items(): + + if hasattr(value, "value"): + value = value.value + + if isinstance(value, list) and value and hasattr(value[0], "value"): + value = [item.value for item in value] + setattr(venue, field, value) + + if venue.pricing_mode == "flat": + if venue.starting_price_paise is None: + raise ConflictError("starting_price_paise is required when pricing_mode is 'flat'") + if venue.hourly_rate_paise is not None: + raise ConflictError("hourly_rate_paise must be null when pricing_mode is 'flat'") + elif venue.pricing_mode == "hourly": + if venue.hourly_rate_paise is None: + raise ConflictError("hourly_rate_paise is required when pricing_mode is 'hourly'") + if venue.starting_price_paise is not None: + raise ConflictError("starting_price_paise must be null when pricing_mode is 'hourly'") + elif venue.pricing_mode == "mixed": + if venue.starting_price_paise is None or venue.hourly_rate_paise is None: + raise ConflictError("Both starting_price_paise and hourly_rate_paise are required when pricing_mode is 'mixed'") + + if venue.min_capacity is not None and venue.min_capacity > venue.max_capacity: + raise ConflictError("min_capacity cannot exceed max_capacity") + if venue.min_booking_duration_minutes > venue.max_booking_duration_minutes: + raise ConflictError("min_booking_duration_minutes cannot exceed max_booking_duration_minutes") + if venue.min_price_pct > venue.max_price_pct: + raise ConflictError("min_price_pct cannot exceed max_price_pct") + + _recompute_display_price_range(venue) + + db.commit() + db.refresh(venue) + + from app.modules.search.indexer import enqueue_job + enqueue_job(db, venue.id, "update") + + return venue + + +def delete_venue(db: Session, venue_id: UUID, owner_id: UUID) -> None: + + venue = _get_venue_or_404(db, venue_id) + _assert_owner(venue, owner_id) + + if venue.status not in (VenueStatus.draft, VenueStatus.rejected, VenueStatus.suspended): + raise ConflictError( + f"Venue cannot be deleted in status '{venue.status.value}'. " + "Only draft, rejected, or suspended venues can be deleted." + ) + + venue.deleted_at = datetime.now(timezone.utc) + db.commit() + + +def submit_venue(db: Session, venue_id: UUID, owner_id: UUID) -> Venue: + + venue = _get_venue_or_404(db, venue_id) + _assert_owner(venue, owner_id) + + allowed_from = (VenueStatus.draft, VenueStatus.rejected) + if venue.status not in allowed_from: + raise ConflictError( + f"Cannot submit venue in status '{venue.status.value}'. " + "Only draft or rejected venues can be submitted for review." + ) + + if len(venue.photos) < MIN_VENUE_PHOTOS: + raise ConflictError( + f"At least {MIN_VENUE_PHOTOS} photos are required before submitting " + f"for review. This venue currently has {len(venue.photos)}." + ) + + venue.status = VenueStatus.pending_approval + db.commit() + db.refresh(venue) + return venue + + +def get_venue_availability(db: Session, venue_id: UUID) -> list[VenueAvailability]: + _get_active_venue_or_404(db, venue_id) + return ( + db.query(VenueAvailability) + .filter( + VenueAvailability.venue_id == venue_id, + VenueAvailability.deleted_at.is_(None), + ) + .order_by(VenueAvailability.day_of_week.asc()) + .all() + ) + + +def bulk_update_availability( + db: Session, + venue_id: UUID, + owner_id: UUID, + availabilities: list[VenueAvailabilityUpdate], +) -> list[VenueAvailability]: + + venue = _get_venue_or_404(db, venue_id) + _assert_owner(venue, owner_id) + + now = datetime.now(timezone.utc) + db.query(VenueAvailability).filter( + VenueAvailability.venue_id == venue_id, + VenueAvailability.deleted_at.is_(None) + ).update({"deleted_at": now}, synchronize_session=False) + + new_records = [] + for item in availabilities: + new_record = VenueAvailability( + id=uuid.uuid4(), + venue_id=venue_id, + day_of_week=item.day_of_week, + is_available=item.is_available, + opens_at=item.opens_at, + closes_at=item.closes_at, + spans_next_day=item.spans_next_day, + ) + db.add(new_record) + new_records.append(new_record) + + db.commit() + for rec in new_records: + db.refresh(rec) + return sorted(new_records, key=lambda x: x.day_of_week) + + +def get_venue_blocked_dates(db: Session, venue_id: UUID) -> list[VenueBlockedDate]: + _get_active_venue_or_404(db, venue_id) + now = datetime.now(timezone.utc) + return ( + db.query(VenueBlockedDate) + .filter( + VenueBlockedDate.venue_id == venue_id, + VenueBlockedDate.deleted_at.is_(None), + VenueBlockedDate.ends_at > now, + ) + .order_by(VenueBlockedDate.starts_at.asc()) + .all() + ) + + +def create_blocked_date( + db: Session, + venue_id: UUID, + owner_id: UUID, + body: CreateBlockedDateRequest, +) -> VenueBlockedDate: + venue = _get_venue_or_404(db, venue_id) + _assert_owner(venue, owner_id) + + new_block = VenueBlockedDate( + id=uuid.uuid4(), + venue_id=venue_id, + starts_at=body.starts_at, + ends_at=body.ends_at, + reason=body.reason, + blocked_by=owner_id, + ) + db.add(new_block) + db.commit() + db.refresh(new_block) + return new_block + + +def delete_blocked_date(db: Session, venue_id: UUID, blocked_id: UUID, owner_id: UUID) -> None: + venue = _get_venue_or_404(db, venue_id) + _assert_owner(venue, owner_id) + + blocked_date = db.query(VenueBlockedDate).filter( + VenueBlockedDate.id == blocked_id, + VenueBlockedDate.venue_id == venue_id, + VenueBlockedDate.deleted_at.is_(None), + ).first() + + if not blocked_date: + raise NotFoundError("Blocked date not found") + + blocked_date.deleted_at = datetime.now(timezone.utc) + db.commit() + + +# Pricing rules + +def _rule_exceeds_bounds(rule: VenuePricingRule, venue: Venue) -> bool: + if rule.adjustment_type != "multiplier" or rule.multiplier is None: + return False + rule_pct = Decimal(str(rule.multiplier)) * Decimal("100") + return rule_pct < Decimal(str(venue.min_price_pct)) or rule_pct > Decimal(str(venue.max_price_pct)) + + +def _rule_to_response(rule: VenuePricingRule, venue: Venue) -> VenuePricingRuleResponse: + return VenuePricingRuleResponse.model_validate( + rule, from_attributes=True + ).model_copy(update={"exceeds_bounds": _rule_exceeds_bounds(rule, venue)}) + + +def _recompute_display_price_range(venue: Venue) -> None: + """Recompute the venue's listing display range from its active multiplier rules. + Called transactionally on every pricing-rule / bounds write (PRD §6.6): no worker, + no cache invalidation -- just a cheap read-time-equivalent computation stored for + index-only listing queries. + """ + base = venue.starting_price_paise if venue.pricing_mode in ("flat", "mixed") else venue.hourly_rate_paise + + active_multiplier_rules = [ + r for r in venue.pricing_rules + if r.is_active and r.adjustment_type == "multiplier" and r.multiplier is not None + ] + + if base is None or not active_multiplier_rules: + venue.display_price_min_paise = None + venue.display_price_max_paise = None + return + + multipliers = [Decimal(str(r.multiplier)) for r in active_multiplier_rules] + low_multiplier = min(Decimal("1.0"), min(multipliers)) + high_multiplier = max(Decimal("1.0"), max(multipliers)) + + min_pct = Decimal(str(venue.min_price_pct)) + max_pct = Decimal(str(venue.max_price_pct)) + + display_min, _ = pricing_engine.clamp_price( + _banker_round(Decimal(str(base)) * low_multiplier), base, min_pct, max_pct + ) + display_max, _ = pricing_engine.clamp_price( + _banker_round(Decimal(str(base)) * high_multiplier), base, min_pct, max_pct + ) + venue.display_price_min_paise = display_min + venue.display_price_max_paise = display_max + + +def list_pricing_rules(db: Session, venue_id: UUID, owner_id: UUID) -> list[VenuePricingRuleResponse]: + venue = _get_venue_or_404(db, venue_id) + _assert_owner(venue, owner_id) + + rules = ( + db.query(VenuePricingRule) + .filter(VenuePricingRule.venue_id == venue_id, VenuePricingRule.deleted_at.is_(None)) + .order_by(VenuePricingRule.priority.desc(), VenuePricingRule.created_at.desc()) + .all() + ) + return [_rule_to_response(r, venue) for r in rules] + + +def _get_pricing_rule_or_404(db: Session, venue_id: UUID, rule_id: UUID) -> VenuePricingRule: + rule = db.query(VenuePricingRule).filter( + VenuePricingRule.id == rule_id, + VenuePricingRule.venue_id == venue_id, + VenuePricingRule.deleted_at.is_(None), + ).first() + if not rule: + raise NotFoundError("Pricing rule not found") + return rule + + +def create_pricing_rule( + db: Session, + venue_id: UUID, + owner_id: UUID, + body: CreatePricingRuleRequest, +) -> VenuePricingRuleResponse: + venue = _get_venue_or_404(db, venue_id) + _assert_owner(venue, owner_id) + + if body.is_active: + active_count = db.query(VenuePricingRule).filter( + VenuePricingRule.venue_id == venue_id, + VenuePricingRule.deleted_at.is_(None), + VenuePricingRule.is_active.is_(True), + ).count() + if active_count >= MAX_ACTIVE_PRICING_RULES_PER_VENUE: + raise ConflictError( + f"Venue already has the maximum of {MAX_ACTIVE_PRICING_RULES_PER_VENUE} active pricing rules" + ) + + rule = VenuePricingRule( + id=uuid.uuid4(), + venue_id=venue_id, + name=body.name, + days_of_week=body.days_of_week, + start_date=body.start_date, + end_date=body.end_date, + start_time=body.start_time, + end_time=body.end_time, + adjustment_type=body.adjustment_type.value, + multiplier=body.multiplier, + amount_paise=body.amount_paise, + applies_to=body.applies_to.value, + priority=body.priority, + source="owner", + is_active=body.is_active, + ) + db.add(rule) + db.flush() + db.refresh(venue) + + _recompute_display_price_range(venue) + db.commit() + db.refresh(rule) + return _rule_to_response(rule, venue) + + +def update_pricing_rule( + db: Session, + venue_id: UUID, + rule_id: UUID, + owner_id: UUID, + body: UpdatePricingRuleRequest, +) -> VenuePricingRuleResponse: + venue = _get_venue_or_404(db, venue_id) + _assert_owner(venue, owner_id) + rule = _get_pricing_rule_or_404(db, venue_id, rule_id) + + if body.is_active and not rule.is_active: + active_count = db.query(VenuePricingRule).filter( + VenuePricingRule.venue_id == venue_id, + VenuePricingRule.deleted_at.is_(None), + VenuePricingRule.is_active.is_(True), + ).count() + if active_count >= MAX_ACTIVE_PRICING_RULES_PER_VENUE: + raise ConflictError( + f"Venue already has the maximum of {MAX_ACTIVE_PRICING_RULES_PER_VENUE} active pricing rules" + ) + + update_data = body.model_dump(exclude_unset=True) + for field, value in update_data.items(): + if hasattr(value, "value"): + value = value.value + setattr(rule, field, value) + + if rule.adjustment_type == "multiplier": + if rule.multiplier is None: + raise ConflictError("multiplier is required when adjustment_type is 'multiplier'") + if rule.amount_paise is not None: + raise ConflictError("amount_paise must be null when adjustment_type is 'multiplier'") + else: + if rule.amount_paise is None: + raise ConflictError("amount_paise is required when adjustment_type is 'fixed_delta' or 'override'") + if rule.multiplier is not None: + raise ConflictError("multiplier must be null when adjustment_type is not 'multiplier'") + if rule.adjustment_type == "override" and rule.amount_paise < 0: + raise ConflictError("amount_paise must be >= 0 when adjustment_type is 'override'") + + if rule.start_date is not None and rule.end_date is not None and rule.start_date > rule.end_date: + raise ConflictError("start_date cannot be after end_date") + + db.flush() + db.refresh(venue) + _recompute_display_price_range(venue) + db.commit() + db.refresh(rule) + return _rule_to_response(rule, venue) + + +def delete_pricing_rule(db: Session, venue_id: UUID, rule_id: UUID, owner_id: UUID) -> None: + venue = _get_venue_or_404(db, venue_id) + _assert_owner(venue, owner_id) + rule = _get_pricing_rule_or_404(db, venue_id, rule_id) + + rule.deleted_at = datetime.now(timezone.utc) + db.flush() + db.refresh(venue) + _recompute_display_price_range(venue) + db.commit() + + +def get_venue_cancellation_policy(db: Session, venue_id: UUID) -> VenueCancellationPolicy: + _get_active_venue_or_404(db, venue_id) + policy = db.query(VenueCancellationPolicy).filter( + VenueCancellationPolicy.venue_id == venue_id + ).first() + + if not policy: + raise NotFoundError("Cancellation policy not found for this venue") + + return policy + + +def put_venue_cancellation_policy( + db: Session, + venue_id: UUID, + owner_id: UUID, + body: UpdateCancellationPolicyRequest, +) -> VenueCancellationPolicy: + venue = _get_venue_or_404(db, venue_id) + _assert_owner(venue, owner_id) + + policy = db.query(VenueCancellationPolicy).filter( + VenueCancellationPolicy.venue_id == venue_id + ).first() + + if not policy: + policy = VenueCancellationPolicy( + id=uuid.uuid4(), + venue_id=venue_id, + ) + db.add(policy) + + policy.tier_1_hours = body.tier_1_hours + policy.tier_1_refund_pct = body.tier_1_refund_pct + policy.tier_2_hours = body.tier_2_hours + policy.tier_2_refund_pct = body.tier_2_refund_pct + policy.tier_3_hours = body.tier_3_hours + policy.tier_3_refund_pct = body.tier_3_refund_pct + policy.no_show_refund_pct = body.no_show_refund_pct + policy.platform_fee_refundable = body.platform_fee_refundable + policy.notes = body.notes + + db.commit() + db.refresh(policy) + return policy + + +def update_venue_amenities( + db: Session, + venue_id: UUID, + owner_id: UUID, + body: UpdateVenueAmenitiesRequest, +) -> list[Amenity]: + venue = _get_venue_or_404(db, venue_id) + _assert_owner(venue, owner_id) + + if body.amenity_ids: + valid_amenities = db.query(Amenity).filter( + Amenity.id.in_(body.amenity_ids), + Amenity.deleted_at.is_(None), + ).all() + if len(valid_amenities) != len(set(body.amenity_ids)): + raise ConflictError("One or more amenity IDs provided do not exist in the platform.") + + db.query(VenueAmenity).filter(VenueAmenity.venue_id == venue_id).delete(synchronize_session=False) + + new_links = [] + for am_id in set(body.amenity_ids): + new_links.append(VenueAmenity(venue_id=venue_id, amenity_id=am_id)) + + if new_links: + db.add_all(new_links) + + db.commit() + db.refresh(venue) + return venue.amenities + + +def add_venue_photo( + db: Session, + venue_id: UUID, + owner_id: UUID, + file_bytes: bytes, +) -> VenuePhoto: + venue = _get_venue_or_404(db, venue_id) + _assert_owner(venue, owner_id) + + + image_url = upload_image_to_cloudinary(file_bytes, folder=f"venues/{venue_id}") + + + existing_photos = db.query(VenuePhoto).filter( + VenuePhoto.venue_id == venue_id, + VenuePhoto.deleted_at.is_(None) + ).all() + + sort_order = len(existing_photos) + is_cover = True if sort_order == 0 else False + + photo = VenuePhoto( + id=uuid.uuid4(), + venue_id=venue_id, + image_url=image_url, + sort_order=sort_order, + is_cover=is_cover + ) + db.add(photo) + db.commit() + db.refresh(photo) + return photo + + +def bulk_update_venue_photos( + db: Session, + venue_id: UUID, + owner_id: UUID, + body: BulkUpdateVenuePhotosRequest, +) -> list[VenuePhoto]: + venue = _get_venue_or_404(db, venue_id) + _assert_owner(venue, owner_id) + + cover_count = sum(1 for p in body.photos if p.is_cover) + if cover_count != 1: + raise ConflictError("Exactly one photo must be marked as the cover photo.") + + photo_ids_in_request = {p.photo_id for p in body.photos} + + + existing_photos = db.query(VenuePhoto).filter( + VenuePhoto.venue_id == venue_id, + VenuePhoto.deleted_at.is_(None) + ).all() + + existing_photo_map = {p.id: p for p in existing_photos} + + if len(photo_ids_in_request) != len(existing_photos): + raise ConflictError("The request must include all active photos for this venue.") + + for p_id in photo_ids_in_request: + if p_id not in existing_photo_map: + raise NotFoundError(f"Photo with ID {p_id} not found in this venue") + + + + for photo in existing_photos: + photo.is_cover = False + db.flush() + + + for item in body.photos: + photo = existing_photo_map[item.photo_id] + photo.sort_order = item.sort_order + photo.is_cover = item.is_cover + + db.commit() + + return sorted(existing_photos, key=lambda p: p.sort_order) + + +def delete_venue_photo(db: Session, venue_id: UUID, photo_id: UUID, owner_id: UUID) -> None: + venue = _get_venue_or_404(db, venue_id) + _assert_owner(venue, owner_id) + + photo = db.query(VenuePhoto).filter( + VenuePhoto.id == photo_id, + VenuePhoto.venue_id == venue_id, + VenuePhoto.deleted_at.is_(None) + ).first() + + if not photo: + raise NotFoundError("Photo not found") + + photo.deleted_at = datetime.now(timezone.utc) + + if photo.is_cover: + photo.is_cover = False + next_photo = db.query(VenuePhoto).filter( + VenuePhoto.venue_id == venue_id, + VenuePhoto.id != photo_id, + VenuePhoto.deleted_at.is_(None) + ).order_by(VenuePhoto.sort_order.asc()).first() + + if next_photo: + next_photo.is_cover = True + db.add(next_photo) + + db.commit() + + +def get_pricing_quote( + db: Session, + venue_id: UUID, + starts_at: datetime, + ends_at: datetime, + booking_type: BookingType, +) -> PricingQuote: + + preview = get_pricing_preview( + db=db, + venue_id=venue_id, + starts_at=starts_at, + ends_at=ends_at, + booking_type=booking_type, + ) + + return PricingQuote( + quoted_price_paise=preview.quoted_price_paise, + platform_commission_pct=preview.platform_commission_pct, + platform_fee_paise=preview.platform_fee_paise, + owner_payout_paise=preview.owner_payout_paise, + advance_pct=preview.advance_pct, + advance_due_paise=preview.advance_due_paise, + balance_due_paise=preview.balance_due_paise, + pricing_mode=preview.pricing_mode, + breakdown=preview.breakdown, + clamped=preview.clamped, + ) + + +def get_pricing_quote_for_slot( + db: Session, + venue_id: UUID, + starts_at: datetime, + ends_at: datetime, + booking_type: str, +) -> PricingQuote: + """ + Get pricing quote for a booking slot. + For use by availability and booking modules. + """ + + return get_pricing_quote( + db=db, + venue_id=venue_id, + starts_at=starts_at, + ends_at=ends_at, + booking_type=BookingType(booking_type), + ) + +def get_venue_stats_this_month(db: Session, venue_id: UUID, owner_id: UUID) -> dict: + venue = _get_venue_or_404(db, venue_id) + if venue.owner_id != owner_id: + raise ForbiddenError("Not your venue") + + now = datetime.now(timezone.utc) + start_of_month = datetime(now.year, now.month, 1, tzinfo=timezone.utc) + + from app.modules.booking.models import BookingSlot + + active_bookings = ( + db.query(func.count(Booking.id)) + .join(Booking.slot) + .filter( + Booking.venue_id == venue_id, + BookingSlot.ends_at >= now, + Booking.status == BookingStatus.confirmed + ) + .scalar() or 0 + ) + + ledger_rows = ( + db.query( + LedgerEntry.entry_type, + LedgerEntry.direction, + func.sum(LedgerEntry.amount_paise).label("total"), + ) + .filter( + LedgerEntry.venue_id == venue_id, + LedgerEntry.created_at >= start_of_month, + ) + .group_by(LedgerEntry.entry_type, LedgerEntry.direction) + .all() + ) + + gross_volume = 0 + platform_fees = 0 + refunds_issued = 0 + + for entry_type, direction, total in ledger_rows: + val = int(total or 0) + if entry_type == "charge" and direction == "credit": + gross_volume += val + elif entry_type == "platform_fee" and direction == "debit": + platform_fees += val + elif entry_type == "refund" and direction == "debit": + refunds_issued += val + + net_revenue = gross_volume - platform_fees - refunds_issued + + return { + "active_bookings": active_bookings, + "revenue_this_month_paise": net_revenue + } diff --git a/apps/api/app/shared/models.py b/apps/api/app/shared/models.py new file mode 100644 index 000000000..b4965f996 --- /dev/null +++ b/apps/api/app/shared/models.py @@ -0,0 +1,12 @@ +from datetime import datetime +from sqlalchemy import DateTime, func +from sqlalchemy.orm import mapped_column, Mapped + + +class TimestampMixin: + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=func.now(), nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=func.now(), onupdate=func.now(), nullable=False + ) diff --git a/apps/api/app/shared/pagination.py b/apps/api/app/shared/pagination.py new file mode 100644 index 000000000..681413202 --- /dev/null +++ b/apps/api/app/shared/pagination.py @@ -0,0 +1,16 @@ +from pydantic import BaseModel +from typing import Generic, TypeVar, List + +T = TypeVar("T") + + +class PaginationParams(BaseModel): + page: int = 1 + page_size: int = 20 + + +class Page(BaseModel, Generic[T]): + items: List[T] + total: int + page: int + page_size: int diff --git a/apps/api/app/shared/utils.py b/apps/api/app/shared/utils.py new file mode 100644 index 000000000..cb94204fb --- /dev/null +++ b/apps/api/app/shared/utils.py @@ -0,0 +1,30 @@ +import uuid +from datetime import datetime +from fastapi import HTTPException + + +def generate_id() -> str: + return str(uuid.uuid4()) + + +# Helper to parse datetime query parameters that might have '+' decoded as space +def parse_timezone_datetime(val: str, field_name: str) -> datetime: + processed_val = val + if " " in val: + parts = val.split(" ") + if len(parts) == 2 and (":" in parts[1] or parts[1].isdigit()): + processed_val = "+".join(parts) + try: + return datetime.fromisoformat(processed_val) + except ValueError as e: + raise HTTPException( + status_code=422, + detail=[ + { + "type": "datetime_from_date_parsing", + "loc": ["query", field_name], + "msg": f"Input should be a valid datetime or date, unexpected extra characters at the end of the input: {str(e)}", + "input": val, + } + ], + ) diff --git a/apps/api/app/tests/test_availability_service.py b/apps/api/app/tests/test_availability_service.py new file mode 100644 index 000000000..315115c4c --- /dev/null +++ b/apps/api/app/tests/test_availability_service.py @@ -0,0 +1,88 @@ +from datetime import date, datetime, time, timezone, timedelta +from zoneinfo import ZoneInfo + +from app.modules.availability.service import ( + resolve_operating_window, + expand_full_day_slot, + compute_effective_range, +) +from app.modules.booking.models import BookingType +from app.modules.venue.models import Venue +import app.modules.venue.service as venue_service + + +def make_venue(tz="Asia/Kolkata"): + v = Venue() + v.timezone = tz + v.open_time = time(9, 0) + v.close_time = time(18, 0) + v.spans_next_day = False + v.pricing_mode = "hourly" + v.hourly_rate_paise = 10000 + v.platform_commission_pct = 10.00 + v.advance_pct = 30.00 + return v + + +def test_resolve_and_expand_basic(): + v = make_venue() + op = resolve_operating_window(v, date(2026, 6, 7)) + assert op.is_available is True + assert op.opens_at == time(9, 0) + + starts_utc, ends_utc = expand_full_day_slot(v, date(2026, 6, 7)) + # Asia/Kolkata is UTC+5:30 + assert starts_utc.tzinfo is not None + assert ends_utc.tzinfo is not None + assert (ends_utc - starts_utc).seconds == (18 - 9) * 3600 + + +def test_compute_effective_range(): + tz = ZoneInfo("Asia/Kolkata") + starts = datetime(2026, 6, 7, 9, 0, tzinfo=tz) + ends = datetime(2026, 6, 7, 18, 0, tzinfo=tz) + es, ee = compute_effective_range(starts, ends, 15, 20) + assert es == starts - timedelta(minutes=15) + assert ee == ends + timedelta(minutes=20) + + +def test_pricing_quote_hourly(monkeypatch): + v = make_venue() + tz = ZoneInfo(v.timezone) + starts = datetime(2026, 6, 7, 10, 0, tzinfo=tz).astimezone(timezone.utc) + ends = datetime(2026, 6, 7, 12, 30, tzinfo=tz).astimezone(timezone.utc) + + # Pricing now resolves the venue from the DB; stub that lookup so we can + # exercise the (DB-free) pricing math on our in-memory venue. + monkeypatch.setattr(venue_service, "_get_active_venue_or_404", lambda db, venue_id: v) + q = venue_service.get_pricing_quote( + db=None, venue_id=None, starts_at=starts, ends_at=ends, + booking_type=BookingType.time_slot, + ) + # duration 2.5 hours * 10000 paise = 25000 paise + assert q.quoted_price_paise == 25000 + assert q.advance_due_paise == int(25000 * 0.30) + + +def test_expand_full_day_handles_dst_forward(): + # London DST starts 2026-03-29 (clocks forward 1 hour) -> day length 23 hours + v = make_venue(tz="Europe/London") + v.open_time = time(0, 0) + v.close_time = time(0, 0) + v.spans_next_day = True + + starts_utc, ends_utc = expand_full_day_slot(v, date(2026, 3, 29)) + delta = ends_utc - starts_utc + assert delta.total_seconds() == 23 * 3600 + + +def test_expand_full_day_handles_dst_backward(): + # London DST ends 2026-10-25 (clocks back 1 hour) -> day length 25 hours + v = make_venue(tz="Europe/London") + v.open_time = time(0, 0) + v.close_time = time(0, 0) + v.spans_next_day = True + + starts_utc, ends_utc = expand_full_day_slot(v, date(2026, 10, 25)) + delta = ends_utc - starts_utc + assert delta.total_seconds() == 25 * 3600 diff --git a/apps/api/pyproject.toml b/apps/api/pyproject.toml new file mode 100644 index 000000000..9d5ad3175 --- /dev/null +++ b/apps/api/pyproject.toml @@ -0,0 +1,51 @@ +[project] +name = "venue404-api" +version = "0.0.1" +requires-python = ">=3.12" +dependencies = [ + "fastapi>=0.111.0", + "uvicorn[standard]>=0.30.0", + "sqlalchemy>=2.0.0", + "alembic>=1.13.0", + "psycopg2-binary>=2.9.0", + "pydantic-settings>=2.3.0", + "email-validator>=2.2.0", + "passlib[bcrypt]>=1.7.4", + "python-jose[cryptography]>=3.3.0", + "stripe>=9.0.0", + "apscheduler>=3.10.0", + "resend>=2.0.0", + "cloudinary>=1.40.0", + "pillow>=10.0.0", + "python-multipart>=0.0.9", + "pgvector>=0.3.0", + "upstash-redis>=1.0.0", + "httpx>=0.27.0", + "rapidfuzz>=3.14.5", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0.0", + "pytest-asyncio>=0.23.0", + "pytest-dotenv>=0.5.2", + "ruff>=0.5.0", +] + +[tool.pytest.ini_options] +env_files = [".env.test"] + +[tool.ruff] +target-version = "py312" +line-length = 100 + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "UP"] + +[build-system] +requires = ["setuptools>=69", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +include = ["app*"] +exclude = ["tests*", "alembic*"] diff --git a/apps/api/scripts/reindex_all_venues.py b/apps/api/scripts/reindex_all_venues.py new file mode 100644 index 000000000..5ce84515f --- /dev/null +++ b/apps/api/scripts/reindex_all_venues.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +""" +Re-enqueue ALL venues for search indexing. +""" + +import logging +import sys + +from app.core.database import SessionLocal +from app.modules.search.indexer import enqueue_job +from app.modules.venue.models import Venue, VenueStatus +from app.modules.booking.models import Booking +from app.modules.profile.models import Profile + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +def reindex_all_venues(batch_size: int = 200): + db = SessionLocal() + processed = 0 + total = 0 + + try: + # Count total venues + total = db.query(Venue).filter(Venue.deleted_at.is_(None)).count() + logger.info(f"Found {total} venues to reindex.") + + offset = 0 + + while True: + venues = ( + db.query(Venue) + .filter(Venue.deleted_at.is_(None)) + # .filter(Venue.status == VenueStatus.approved) # Uncomment if needed + # .filter(Venue.is_active == True) + .order_by(Venue.id) + .offset(offset) + .limit(batch_size) + .all() + ) + + if not venues: + break + + for venue in venues: + try: + enqueue_job(db, venue.id, "reindex") + processed += 1 + if processed % 50 == 0: + logger.info(f"Progress: {processed}/{total} venues enqueued") + except Exception as e: + logger.error(f"Failed to enqueue venue {venue.id}: {e}") + + offset += batch_size + db.commit() # Commit periodically + + logger.info(f"✅ Successfully enqueued {processed} out of {total} venues.") + + except Exception as exc: + logger.exception("Reindexing failed") + sys.exit(1) + finally: + db.close() + + +if __name__ == "__main__": + reindex_all_venues() diff --git a/apps/api/scripts/run_job.py b/apps/api/scripts/run_job.py new file mode 100644 index 000000000..6d03a9b8b --- /dev/null +++ b/apps/api/scripts/run_job.py @@ -0,0 +1,59 @@ +import logging +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) + +from app.core.logging import setup_logging + +setup_logging() + +logger = logging.getLogger(__name__) + +import app.modules.venue.models # noqa: F401 +import app.modules.profile.models # noqa: F401 +import app.modules.booking.models # noqa: F401 + +from app.jobs.runner import JOBS, run_job as _run_job + + +def run_job(job_name: str) -> bool: + if job_name not in JOBS: + print(f"Unknown job: {job_name}") + return False + + print(f"\n🚀 Running {job_name}") + + try: + processed = _run_job(job_name) + print(f"✅ {job_name} completed. Processed: {processed}") + return True + + except Exception: + logger.exception("%s failed", job_name) + print(f"❌ {job_name} failed") + return False + + +def run_all(): + print("Running ALL jobs...\n") + + success = 0 + + for job_name in JOBS: + if run_job(job_name): + success += 1 + + print(f"\nCompleted {success}/{len(JOBS)} jobs.") + + +if __name__ == "__main__": + if len(sys.argv) > 1 and sys.argv[1] == "all": + run_all() + elif len(sys.argv) > 1: + run_job(sys.argv[1]) + else: + print("Usage:") + print(" python run_job.pyx
") is False + + +def test_smtp_fallback_is_used(monkeypatch): + monkeypatch.setattr(settings, "resend_api_key", "") + monkeypatch.setattr(settings, "smtp_host", "smtp.example.com") + monkeypatch.setattr(settings, "smtp_user", "") + + captured = {} + + class FakeSMTP: + def __init__(self, host, port): + captured["host"] = host + captured["port"] = port + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def starttls(self): + captured["starttls"] = True + + def login(self, user, password): + captured["login"] = (user, password) + + def send_message(self, msg): + captured["to"] = msg["To"] + + monkeypatch.setattr(smtplib, "SMTP", FakeSMTP) + + assert email_mod.send_email("guest@example.com", "Hi", "hi
") is True + assert captured["host"] == "smtp.example.com" + assert captured["to"] == "guest@example.com" + assert captured.get("starttls") is True + + +def test_resend_is_preferred_when_key_set(monkeypatch): + monkeypatch.setattr(settings, "resend_api_key", "re_test") + calls = {} + + def fake_resend(to, s, h): + calls["resend"] = (to, s) + return True + + def fake_smtp(*a): + calls["smtp"] = True + return True + + monkeypatch.setattr(email_mod, "_send_via_resend", fake_resend) + monkeypatch.setattr(email_mod, "_send_via_smtp", fake_smtp) + assert email_mod.send_email("x@y.com", "S", "b
") is True + assert "resend" in calls and "smtp" not in calls diff --git a/apps/api/tests/test_hold_expiry.py b/apps/api/tests/test_hold_expiry.py new file mode 100644 index 000000000..53d311adf --- /dev/null +++ b/apps/api/tests/test_hold_expiry.py @@ -0,0 +1,94 @@ +""" +Hold expiry job tests. + +Business rule (CLAUDE.md): + If token payment is not completed within 24 hours: + accepted -> hold_expired + The slot must be unblocked so the owner can accept another requester. +""" +from datetime import datetime, timedelta, timezone + +from app.jobs.hold_expiry import run as run_hold_expiry +from app.modules.booking.models import Booking, BookingSlot, BookingStatus +from tests.conftest import create_booking, seed_approved_venue, seed_user + + +def _accept_booking(client, owner_token: str, booking_id: str): + return client.post( + f"/api/bookings/{booking_id}/accept", + headers={"Authorization": f"Bearer {owner_token}"}, + ) + + +def _expire_hold(db, booking_id: str): + """Force hold_expires_at into the past to simulate expiry.""" + db.query(Booking).filter(Booking.id == booking_id).update( + {"hold_expires_at": datetime.now(timezone.utc) - timedelta(hours=25)} + ) + db.commit() + + +def test_expired_hold_transitions_to_hold_expired(client, db, category_id): + owner_id, owner_token = seed_user(db, "venue_owner") + _, customer_token = seed_user(db, "customer") + venue_id = seed_approved_venue(db, owner_id, category_id) + + booking = create_booking(client, customer_token, venue_id).json() + _accept_booking(client, owner_token, booking["id"]) + _expire_hold(db, booking["id"]) + + processed = run_hold_expiry() + + assert processed == 1 + db.expire_all() + row = db.query(Booking).filter(Booking.id == booking["id"]).first() + assert row.status == BookingStatus.hold_expired + + +def test_expired_hold_unblocks_slot(client, db, category_id): + owner_id, owner_token = seed_user(db, "venue_owner") + _, customer_token = seed_user(db, "customer") + venue_id = seed_approved_venue(db, owner_id, category_id) + + booking = create_booking(client, customer_token, venue_id).json() + _accept_booking(client, owner_token, booking["id"]) + _expire_hold(db, booking["id"]) + + run_hold_expiry() + + db.expire_all() + slot = db.query(BookingSlot).filter(BookingSlot.booking_id == booking["id"]).first() + assert slot.is_blocking is False + + +def test_non_expired_hold_is_not_affected(client, db, category_id): + owner_id, owner_token = seed_user(db, "venue_owner") + _, customer_token = seed_user(db, "customer") + venue_id = seed_approved_venue(db, owner_id, category_id) + + booking = create_booking(client, customer_token, venue_id).json() + _accept_booking(client, owner_token, booking["id"]) + # Do NOT expire the hold — it should still be in the future + + processed = run_hold_expiry() + + assert processed == 0 + db.expire_all() + row = db.query(Booking).filter(Booking.id == booking["id"]).first() + assert row.status == BookingStatus.owner_accepted + + +def test_only_accepted_bookings_are_expired(client, db, category_id): + owner_id, _ = seed_user(db, "venue_owner") + _, customer_token = seed_user(db, "customer") + venue_id = seed_approved_venue(db, owner_id, category_id) + + # A plain "requested" booking — never accepted, no hold + booking = create_booking(client, customer_token, venue_id).json() + + processed = run_hold_expiry() + + assert processed == 0 + db.expire_all() + row = db.query(Booking).filter(Booking.id == booking["id"]).first() + assert row.status == BookingStatus.requested diff --git a/apps/api/tests/test_notification_templates.py b/apps/api/tests/test_notification_templates.py new file mode 100644 index 000000000..27c45f609 --- /dev/null +++ b/apps/api/tests/test_notification_templates.py @@ -0,0 +1,28 @@ +from app.modules.notification.templates import render_notification + + +def test_known_type_renders_title_body_and_link(): + title, body, html = render_notification( + "payment_confirmed", {"venue_name": "Hall A"}, booking_id="b-123" + ) + assert "confirmed" in title.lower() + assert "Hall A" in body + assert "/bookings/b-123" in html + + +def test_unknown_type_is_safe(): + title, body, html = render_notification("does_not_exist", {}) + assert title == "Notification" + assert body + + +def test_missing_context_key_does_not_crash(): + # refund_issued references {amount_rupees}; omitting it must not raise + title, body, html = render_notification("refund_issued", {"venue_name": "Hall A"}) + assert title + assert body + + +def test_no_booking_id_means_no_link(): + _, _, html = render_notification("hold_expired", {"venue_name": "Hall A"}) + assert "/bookings/" not in html diff --git a/apps/api/tests/test_pricing_rules.py b/apps/api/tests/test_pricing_rules.py new file mode 100644 index 000000000..31bc9a59c --- /dev/null +++ b/apps/api/tests/test_pricing_rules.py @@ -0,0 +1,273 @@ +from datetime import date, timedelta + +from app.modules.booking.models import Booking +from app.modules.venue.models import VenuePricingRule +from tests.conftest import seed_approved_venue, seed_user + + +def _next_weekday(target_weekday: int, min_days_out: int = 30) -> date: + """Next date (at least `min_days_out` away) matching `target_weekday` (0=Mon..6=Sun).""" + d = date.today() + timedelta(days=min_days_out) + while d.weekday() != target_weekday: + d += timedelta(days=1) + return d + + +def _full_day_window(d: date) -> tuple[str, str]: + return f"{d.isoformat()}T09:00:00+05:30", f"{d.isoformat()}T21:00:00+05:30" + + +def _create_rule(client, token: str, venue_id, **overrides) -> dict: + body = { + "name": "Weekend rate", + "days_of_week": [5, 6], + "adjustment_type": "multiplier", + "multiplier": 1.5, + "applies_to": "full_day", + "priority": 50, + "is_active": True, + } + body.update(overrides) + resp = client.post( + f"/api/venues/{venue_id}/pricing-rules", + json=body, + headers={"Authorization": f"Bearer {token}"}, + ) + return resp + + +# ── Owner CRUD ──────────────────────────────────────────────────────────────── + +def test_create_pricing_rule_as_owner_returns_201(client, db, category_id): + owner_id, token = seed_user(db, "venue_owner") + venue_id = seed_approved_venue(db, owner_id, category_id) + + resp = _create_rule(client, token, venue_id) + + assert resp.status_code == 201 + data = resp.json() + assert data["name"] == "Weekend rate" + assert float(data["multiplier"]) == 1.5 + assert data["exceeds_bounds"] is False + + +def test_create_pricing_rule_as_customer_returns_403(client, db, category_id): + owner_id, _ = seed_user(db, "venue_owner") + _, customer_token = seed_user(db, "customer") + venue_id = seed_approved_venue(db, owner_id, category_id) + + resp = _create_rule(client, customer_token, venue_id) + + assert resp.status_code == 403 + + +def test_list_pricing_rules_after_create(client, db, category_id): + owner_id, token = seed_user(db, "venue_owner") + venue_id = seed_approved_venue(db, owner_id, category_id) + created = _create_rule(client, token, venue_id).json() + + resp = client.get( + f"/api/venues/{venue_id}/pricing-rules", + headers={"Authorization": f"Bearer {token}"}, + ) + + assert resp.status_code == 200 + ids = [r["id"] for r in resp.json()] + assert created["id"] in ids + + +def test_update_pricing_rule(client, db, category_id): + owner_id, token = seed_user(db, "venue_owner") + venue_id = seed_approved_venue(db, owner_id, category_id) + rule = _create_rule(client, token, venue_id).json() + + resp = client.patch( + f"/api/venues/{venue_id}/pricing-rules/{rule['id']}", + json={"priority": 90, "is_active": False}, + headers={"Authorization": f"Bearer {token}"}, + ) + + assert resp.status_code == 200 + data = resp.json() + assert data["priority"] == 90 + assert data["is_active"] is False + + +def test_delete_pricing_rule_soft_deletes(client, db, category_id): + owner_id, token = seed_user(db, "venue_owner") + venue_id = seed_approved_venue(db, owner_id, category_id) + rule = _create_rule(client, token, venue_id).json() + + resp = client.delete( + f"/api/venues/{venue_id}/pricing-rules/{rule['id']}", + headers={"Authorization": f"Bearer {token}"}, + ) + assert resp.status_code == 200 + + listed = client.get( + f"/api/venues/{venue_id}/pricing-rules", + headers={"Authorization": f"Bearer {token}"}, + ).json() + assert rule["id"] not in [r["id"] for r in listed] + + row = db.query(VenuePricingRule).filter(VenuePricingRule.id == rule["id"]).first() + assert row is not None + assert row.deleted_at is not None + + +def test_create_rule_exceeding_bounds_flags_exceeds_bounds(client, db, category_id): + owner_id, token = seed_user(db, "venue_owner") + venue_id = seed_approved_venue(db, owner_id, category_id) + + resp = _create_rule(client, token, venue_id, name="Huge surge", multiplier=3.0) + + assert resp.status_code == 201 + assert resp.json()["exceeds_bounds"] is True # 300% > default max_price_pct (200%) + + +# ── Rule resolution via the public quote endpoint ────────────────────────────── + +def test_zero_rules_prices_identically_to_static(client, db, category_id): + owner_id, token = seed_user(db, "venue_owner") + venue_id = seed_approved_venue(db, owner_id, category_id) + target = _next_weekday(2) # any weekday, no rules exist + starts_at, ends_at = _full_day_window(target) + + resp = client.get( + f"/api/venues/{venue_id}/pricing", + params={"starts_at": starts_at, "ends_at": ends_at, "booking_type": "full_day"}, + ) + + assert resp.status_code == 200 + data = resp.json() + assert data["quoted_price_paise"] == 1_000_000 # base price, untouched + assert data["breakdown"] == [] + assert data["clamped"] is False + + +def test_weekend_rule_adjusts_quoted_price(client, db, category_id): + owner_id, token = seed_user(db, "venue_owner") + venue_id = seed_approved_venue(db, owner_id, category_id) + _create_rule(client, token, venue_id) # +50% Sat/Sun + + saturday = _next_weekday(5) + starts_at, ends_at = _full_day_window(saturday) + + resp = client.get( + f"/api/venues/{venue_id}/pricing", + params={"starts_at": starts_at, "ends_at": ends_at, "booking_type": "full_day"}, + ) + + assert resp.status_code == 200 + data = resp.json() + assert data["quoted_price_paise"] == 1_500_000 # 1,000,000 * 1.5 + assert data["clamped"] is False + assert len(data["breakdown"]) == 1 + assert data["breakdown"][0]["applied_rule_name"] == "Weekend rate" + + +def test_rule_beyond_bounds_is_clamped(client, db, category_id): + owner_id, token = seed_user(db, "venue_owner") + venue_id = seed_approved_venue(db, owner_id, category_id) + _create_rule(client, token, venue_id, name="Huge surge", multiplier=3.0) + + saturday = _next_weekday(5) + starts_at, ends_at = _full_day_window(saturday) + + resp = client.get( + f"/api/venues/{venue_id}/pricing", + params={"starts_at": starts_at, "ends_at": ends_at, "booking_type": "full_day"}, + ) + + assert resp.status_code == 200 + data = resp.json() + assert data["quoted_price_paise"] == 2_000_000 # capped at max_price_pct (200%) of base + assert data["clamped"] is True + assert data["breakdown"][0]["clamped"] is True + + +def test_weekday_unaffected_by_weekend_rule(client, db, category_id): + owner_id, token = seed_user(db, "venue_owner") + venue_id = seed_approved_venue(db, owner_id, category_id) + _create_rule(client, token, venue_id) # Sat/Sun only + + monday = _next_weekday(0) + starts_at, ends_at = _full_day_window(monday) + + resp = client.get( + f"/api/venues/{venue_id}/pricing", + params={"starts_at": starts_at, "ends_at": ends_at, "booking_type": "full_day"}, + ) + + assert resp.status_code == 200 + assert resp.json()["quoted_price_paise"] == 1_000_000 + + +# ── Booking snapshot integration ─────────────────────────────────────────────── + +def test_booking_snapshots_rule_adjusted_price_and_breakdown(client, db, category_id): + owner_id, owner_token = seed_user(db, "venue_owner") + customer_id, customer_token = seed_user(db, "customer") + venue_id = seed_approved_venue(db, owner_id, category_id) + _create_rule(client, owner_token, venue_id) # +50% Sat/Sun + + saturday = _next_weekday(5) + starts_at, ends_at = _full_day_window(saturday) + + resp = client.post( + "/api/bookings/", + json={ + "venue_id": str(venue_id), + "venue_name": "Test Venue", + "venue_cover_image": None, + "booking_type": "full_day", + "starts_at": starts_at, + "ends_at": ends_at, + "guest_count": 10, + "event_type": "corporate", + "user_notes": "pricing rule test", + }, + headers={"Authorization": f"Bearer {customer_token}"}, + ) + + assert resp.status_code == 201 + data = resp.json() + assert data["quoted_price_paise"] == 1_500_000 + + row = db.query(Booking).filter(Booking.id == data["id"]).first() + assert row is not None + assert row.quoted_price_paise == 1_500_000 + assert row.pricing_breakdown is not None + assert row.pricing_breakdown[0]["applied_rule_name"] == "Weekend rate" + + +def test_booking_creation_rejects_stale_price_with_409(client, db, category_id): + owner_id, owner_token = seed_user(db, "venue_owner") + customer_id, customer_token = seed_user(db, "customer") + venue_id = seed_approved_venue(db, owner_id, category_id) + _create_rule(client, owner_token, venue_id) # +50% Sat/Sun + + saturday = _next_weekday(5) + starts_at, ends_at = _full_day_window(saturday) + + resp = client.post( + "/api/bookings/", + json={ + "venue_id": str(venue_id), + "venue_name": "Test Venue", + "venue_cover_image": None, + "booking_type": "full_day", + "starts_at": starts_at, + "ends_at": ends_at, + "guest_count": 10, + "event_type": "corporate", + "user_notes": "stale quote test", + "expected_total_paise": 1_000_000, # stale: doesn't account for the rule + }, + headers={"Authorization": f"Bearer {customer_token}"}, + ) + + assert resp.status_code == 409 + detail = resp.json()["detail"] + assert detail["error"] == "PRICE_CHANGED" + assert detail["quoted_price_paise"] == 1_500_000 diff --git a/apps/api/tests/test_state_machine.py b/apps/api/tests/test_state_machine.py new file mode 100644 index 000000000..37da7ae6a --- /dev/null +++ b/apps/api/tests/test_state_machine.py @@ -0,0 +1,41 @@ +from app.modules.booking.state_machine import can_transition, VALID_TRANSITIONS +from app.modules.booking.models import BookingStatus + +TERMINAL = [ + BookingStatus.completed, + BookingStatus.hold_expired, + BookingStatus.request_expired, + BookingStatus.conflict_cancelled, + BookingStatus.user_cancelled, + BookingStatus.admin_cancelled, + BookingStatus.owner_rejected, + BookingStatus.balance_overdue_cancelled, +] + + +def test_status_count(): + assert len(list(BookingStatus)) == 11 + + +def test_happy_path_request_accept_confirm_complete(): + assert can_transition(BookingStatus.requested, BookingStatus.owner_accepted) + assert can_transition(BookingStatus.owner_accepted, BookingStatus.confirmed) + assert can_transition(BookingStatus.confirmed, BookingStatus.completed) + + +def test_payment_required_before_confirm(): + # cannot jump straight from requested to confirmed + assert not can_transition(BookingStatus.requested, BookingStatus.confirmed) + + +def test_hold_and_conflict_paths(): + assert can_transition(BookingStatus.owner_accepted, BookingStatus.hold_expired) + # conflict cancellation only applies to still-pending (requested) bookings + assert can_transition(BookingStatus.requested, BookingStatus.conflict_cancelled) + assert not can_transition(BookingStatus.owner_accepted, BookingStatus.conflict_cancelled) + + +def test_terminal_states_have_no_exits(): + for s in TERMINAL: + assert VALID_TRANSITIONS[s] == set() + assert not can_transition(s, BookingStatus.requested) diff --git a/apps/api/tests/test_suspended_user.py b/apps/api/tests/test_suspended_user.py new file mode 100644 index 000000000..bb12fc665 --- /dev/null +++ b/apps/api/tests/test_suspended_user.py @@ -0,0 +1,48 @@ +""" +Suspended user access tests. + +Business rule: a suspended user must be denied access to every protected route +even when they hold a valid JWT. +""" +from uuid import uuid4 + +from app.modules.profile.models import Profile, ProfileStatus, UserRole, UserRoleAssignment +from tests.conftest import make_token + + +def _seed_suspended(db, role: str): + user_id = uuid4() + email = f"suspended-{user_id.hex[:6]}@test.com" + db.add(Profile(id=user_id, email=email, status=ProfileStatus.suspended)) + db.add(UserRoleAssignment(user_id=user_id, role=UserRole(role))) + db.commit() + return make_token(user_id, email) + + +def test_suspended_customer_cannot_list_bookings(client, db): + token = _seed_suspended(db, "customer") + resp = client.get("/api/bookings/", headers={"Authorization": f"Bearer {token}"}) + assert resp.status_code == 403 + + +def test_suspended_owner_cannot_list_venues(client, db): + token = _seed_suspended(db, "venue_owner") + resp = client.get("/api/venues/my/venues", headers={"Authorization": f"Bearer {token}"}) + assert resp.status_code == 403 + + +def test_suspended_user_cannot_create_booking(client, db, category_id): + from tests.conftest import create_booking, seed_approved_venue, seed_user + + owner_id, _ = seed_user(db, "venue_owner") + venue_id = seed_approved_venue(db, owner_id, category_id) + suspended_token = _seed_suspended(db, "customer") + + resp = create_booking(client, suspended_token, venue_id) + assert resp.status_code == 403 + + +def test_suspended_admin_cannot_access_admin_routes(client, db): + token = _seed_suspended(db, "super_admin") + resp = client.get("/api/admin/venues", headers={"Authorization": f"Bearer {token}"}) + assert resp.status_code == 403 diff --git a/apps/api/tests/test_venue.py b/apps/api/tests/test_venue.py new file mode 100644 index 000000000..3cbb89046 --- /dev/null +++ b/apps/api/tests/test_venue.py @@ -0,0 +1,78 @@ +from tests.conftest import seed_user + + +VENUE_BODY = { + "name": "Integration Test Venue", + "address_line1": "1 Main Street", + "city": "Mumbai", + "state": "Maharashtra", + "country": "India", + "timezone": "Asia/Kolkata", + "max_capacity": 50, + "open_time": "09:00:00", + "close_time": "21:00:00", + "allowed_booking_types": ["full_day"], + "pricing_mode": "flat", + "starting_price_paise": 500000, + "min_booking_duration_minutes": 60, + "max_booking_duration_minutes": 1440, + "slot_interval_minutes": 30, + "advance_pct": "30.00", + "balance_due_days_before_event": 7, + "owner_action_window_hours": 48, + "overdue_advance_refund_pct": "0.00", +} + + +def test_create_venue_as_owner_returns_201(client, db, category_id): + _, token = seed_user(db, "venue_owner") + + body = {**VENUE_BODY, "category_id": str(category_id)} + resp = client.post( + "/api/venues/", + json=body, + headers={"Authorization": f"Bearer {token}"}, + ) + + assert resp.status_code == 201 + data = resp.json() + assert data["name"] == body["name"] + assert data["city"] == body["city"] + assert data["status"] == "draft" + + +def test_create_venue_row_exists_in_db(client, db, category_id): + from app.modules.venue.models import Venue + + owner_id, token = seed_user(db, "venue_owner") + body = {**VENUE_BODY, "category_id": str(category_id)} + + resp = client.post( + "/api/venues/", + json=body, + headers={"Authorization": f"Bearer {token}"}, + ) + assert resp.status_code == 201 + + venue_id = resp.json()["id"] + row = db.query(Venue).filter(Venue.id == venue_id).first() + assert row is not None + assert str(row.owner_id) == str(owner_id) + + +def test_create_venue_as_customer_returns_403(client, db, category_id): + _, token = seed_user(db, "customer") + + body = {**VENUE_BODY, "category_id": str(category_id)} + resp = client.post( + "/api/venues/", + json=body, + headers={"Authorization": f"Bearer {token}"}, + ) + assert resp.status_code == 403 + + +def test_create_venue_unauthenticated_returns_422(client, category_id): + body = {**VENUE_BODY, "category_id": str(category_id)} + resp = client.post("/api/venues/", json=body) + assert resp.status_code == 422 diff --git a/apps/api/tests/test_venue_visibility.py b/apps/api/tests/test_venue_visibility.py new file mode 100644 index 000000000..b20c49399 --- /dev/null +++ b/apps/api/tests/test_venue_visibility.py @@ -0,0 +1,132 @@ +""" +Venue visibility tests. + +Business rule (CLAUDE.md): a venue is visible to customers only when + status = approved AND active = true AND deleted_at IS NULL + +Every other status must be invisible in public search results. +""" +from datetime import datetime, timezone +from uuid import uuid4 + +from app.modules.venue.models import Venue, VenueStatus +from tests.conftest import seed_approved_venue, seed_user + + +def _seed_venue_with_status(db, owner_id, category_id, status: VenueStatus, is_active: bool = True): + from datetime import time as dt_time + + venue_id = uuid4() + db.add(Venue( + id=venue_id, + owner_id=owner_id, + category_id=category_id, + name=f"Venue {status.value}", + slug=f"venue-{status.value}-{venue_id.hex[:6]}", + address_line1="1 Test St", + city="Mumbai", + state="Maharashtra", + country="India", + timezone="Asia/Kolkata", + max_capacity=50, + open_time=dt_time(9, 0), + close_time=dt_time(21, 0), + allowed_booking_types=["full_day"], + min_booking_duration_minutes=60, + max_booking_duration_minutes=1440, + slot_interval_minutes=30, + pre_buffer_minutes=0, + post_buffer_minutes=0, + pricing_mode="flat", + starting_price_paise=500_000, + platform_commission_pct=10, + advance_pct=30, + balance_due_days_before_event=7, + owner_action_window_hours=48, + overdue_advance_refund_pct=0, + status=status, + is_active=is_active, + )) + db.commit() + return venue_id + + +# ── Search endpoint visibility ──────────────────────────────────────────────── + +def test_approved_active_venue_appears_in_search(client, db, category_id): + owner_id, _ = seed_user(db, "venue_owner") + seed_approved_venue(db, owner_id, category_id) + + resp = client.get("/api/search/") + assert resp.status_code == 200 + assert len(resp.json()["items"]) == 1 + + +def test_draft_venue_hidden_from_search(client, db, category_id): + owner_id, _ = seed_user(db, "venue_owner") + _seed_venue_with_status(db, owner_id, category_id, VenueStatus.draft) + + resp = client.get("/api/search/") + assert resp.status_code == 200 + assert len(resp.json()["items"]) == 0 + + +def test_pending_approval_venue_hidden_from_search(client, db, category_id): + owner_id, _ = seed_user(db, "venue_owner") + _seed_venue_with_status(db, owner_id, category_id, VenueStatus.pending_approval) + + resp = client.get("/api/search/") + assert resp.status_code == 200 + assert len(resp.json()["items"]) == 0 + + +def test_rejected_venue_hidden_from_search(client, db, category_id): + owner_id, _ = seed_user(db, "venue_owner") + _seed_venue_with_status(db, owner_id, category_id, VenueStatus.rejected) + + resp = client.get("/api/search/") + assert resp.status_code == 200 + assert len(resp.json()["items"]) == 0 + + +def test_suspended_venue_hidden_from_search(client, db, category_id): + owner_id, _ = seed_user(db, "venue_owner") + _seed_venue_with_status(db, owner_id, category_id, VenueStatus.suspended) + + resp = client.get("/api/search/") + assert resp.status_code == 200 + assert len(resp.json()["items"]) == 0 + + +def test_approved_but_inactive_venue_hidden_from_search(client, db, category_id): + owner_id, _ = seed_user(db, "venue_owner") + _seed_venue_with_status(db, owner_id, category_id, VenueStatus.approved, is_active=False) + + resp = client.get("/api/search/") + assert resp.status_code == 200 + assert len(resp.json()["items"]) == 0 + + +def test_soft_deleted_venue_hidden_from_search(client, db, category_id): + owner_id, _ = seed_user(db, "venue_owner") + venue_id = seed_approved_venue(db, owner_id, category_id) + + db.query(Venue).filter(Venue.id == venue_id).update( + {"deleted_at": datetime.now(timezone.utc)} + ) + db.commit() + + resp = client.get("/api/search/") + assert resp.status_code == 200 + assert len(resp.json()["items"]) == 0 + + +def test_only_approved_venues_appear_among_mixed(client, db, category_id): + owner_id, _ = seed_user(db, "venue_owner") + seed_approved_venue(db, owner_id, category_id) + _seed_venue_with_status(db, owner_id, category_id, VenueStatus.draft) + _seed_venue_with_status(db, owner_id, category_id, VenueStatus.pending_approval) + + resp = client.get("/api/search/") + assert resp.status_code == 200 + assert len(resp.json()["items"]) == 1 diff --git a/apps/api/uv.lock b/apps/api/uv.lock new file mode 100644 index 000000000..6a7accb78 --- /dev/null +++ b/apps/api/uv.lock @@ -0,0 +1,1603 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "alembic" +version = "1.18.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/94/13/8b084e0f2efb0275a1d534838844926f798bd766566b1375174e2448cd31/alembic-1.18.4.tar.gz", hash = "sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc", size = 2056725, upload-time = "2026-02-10T16:00:47.195Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/29/6533c317b74f707ea28f8d633734dbda2119bbadfc61b2f3640ba835d0f7/alembic-1.18.4-py3-none-any.whl", hash = "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a", size = 263893, upload-time = "2026-02-10T16:00:49.997Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + +[[package]] +name = "apscheduler" +version = "3.11.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzlocal" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/12/3e4389e5920b4c1763390c6d371162f3784f86f85cd6d6c1bfe68eef14e2/apscheduler-3.11.2.tar.gz", hash = "sha256:2a9966b052ec805f020c8c4c3ae6e6a06e24b1bf19f2e11d91d8cca0473eef41", size = 108683, upload-time = "2025-12-22T00:39:34.884Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/64/2e54428beba8d9992aa478bb8f6de9e4ecaa5f8f513bcfd567ed7fb0262d/apscheduler-3.11.2-py3-none-any.whl", hash = "sha256:ce005177f741409db4e4dd40a7431b76feb856b9dd69d57e0da49d6715bfd26d", size = 64439, upload-time = "2025-12-22T00:39:33.303Z" }, +] + +[[package]] +name = "bcrypt" +version = "5.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", size = 25386, upload-time = "2025-09-25T19:50:47.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/85/3e65e01985fddf25b64ca67275bb5bdb4040bd1a53b66d355c6c37c8a680/bcrypt-5.0.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be", size = 481806, upload-time = "2025-09-25T19:49:05.102Z" }, + { url = "https://files.pythonhosted.org/packages/44/dc/01eb79f12b177017a726cbf78330eb0eb442fae0e7b3dfd84ea2849552f3/bcrypt-5.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2", size = 268626, upload-time = "2025-09-25T19:49:06.723Z" }, + { url = "https://files.pythonhosted.org/packages/8c/cf/e82388ad5959c40d6afd94fb4743cc077129d45b952d46bdc3180310e2df/bcrypt-5.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:baade0a5657654c2984468efb7d6c110db87ea63ef5a4b54732e7e337253e44f", size = 271853, upload-time = "2025-09-25T19:49:08.028Z" }, + { url = "https://files.pythonhosted.org/packages/ec/86/7134b9dae7cf0efa85671651341f6afa695857fae172615e960fb6a466fa/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:c58b56cdfb03202b3bcc9fd8daee8e8e9b6d7e3163aa97c631dfcfcc24d36c86", size = 269793, upload-time = "2025-09-25T19:49:09.727Z" }, + { url = "https://files.pythonhosted.org/packages/cc/82/6296688ac1b9e503d034e7d0614d56e80c5d1a08402ff856a4549cb59207/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bfd2a34de661f34d0bda43c3e4e79df586e4716ef401fe31ea39d69d581ef23", size = 289930, upload-time = "2025-09-25T19:49:11.204Z" }, + { url = "https://files.pythonhosted.org/packages/d1/18/884a44aa47f2a3b88dd09bc05a1e40b57878ecd111d17e5bba6f09f8bb77/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ed2e1365e31fc73f1825fa830f1c8f8917ca1b3ca6185773b349c20fd606cec2", size = 272194, upload-time = "2025-09-25T19:49:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/0e/8f/371a3ab33c6982070b674f1788e05b656cfbf5685894acbfef0c65483a59/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:83e787d7a84dbbfba6f250dd7a5efd689e935f03dd83b0f919d39349e1f23f83", size = 269381, upload-time = "2025-09-25T19:49:14.308Z" }, + { url = "https://files.pythonhosted.org/packages/b1/34/7e4e6abb7a8778db6422e88b1f06eb07c47682313997ee8a8f9352e5a6f1/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:137c5156524328a24b9fac1cb5db0ba618bc97d11970b39184c1d87dc4bf1746", size = 271750, upload-time = "2025-09-25T19:49:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/c0/1b/54f416be2499bd72123c70d98d36c6cd61a4e33d9b89562c22481c81bb30/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:38cac74101777a6a7d3b3e3cfefa57089b5ada650dce2baf0cbdd9d65db22a9e", size = 303757, upload-time = "2025-09-25T19:49:17.244Z" }, + { url = "https://files.pythonhosted.org/packages/13/62/062c24c7bcf9d2826a1a843d0d605c65a755bc98002923d01fd61270705a/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:d8d65b564ec849643d9f7ea05c6d9f0cd7ca23bdd4ac0c2dbef1104ab504543d", size = 306740, upload-time = "2025-09-25T19:49:18.693Z" }, + { url = "https://files.pythonhosted.org/packages/d5/c8/1fdbfc8c0f20875b6b4020f3c7dc447b8de60aa0be5faaf009d24242aec9/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:741449132f64b3524e95cd30e5cd3343006ce146088f074f31ab26b94e6c75ba", size = 334197, upload-time = "2025-09-25T19:49:20.523Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c1/8b84545382d75bef226fbc6588af0f7b7d095f7cd6a670b42a86243183cd/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:212139484ab3207b1f0c00633d3be92fef3c5f0af17cad155679d03ff2ee1e41", size = 352974, upload-time = "2025-09-25T19:49:22.254Z" }, + { url = "https://files.pythonhosted.org/packages/10/a6/ffb49d4254ed085e62e3e5dd05982b4393e32fe1e49bb1130186617c29cd/bcrypt-5.0.0-cp313-cp313t-win32.whl", hash = "sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861", size = 148498, upload-time = "2025-09-25T19:49:24.134Z" }, + { url = "https://files.pythonhosted.org/packages/48/a9/259559edc85258b6d5fc5471a62a3299a6aa37a6611a169756bf4689323c/bcrypt-5.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e", size = 145853, upload-time = "2025-09-25T19:49:25.702Z" }, + { url = "https://files.pythonhosted.org/packages/2d/df/9714173403c7e8b245acf8e4be8876aac64a209d1b392af457c79e60492e/bcrypt-5.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5", size = 139626, upload-time = "2025-09-25T19:49:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/f8/14/c18006f91816606a4abe294ccc5d1e6f0e42304df5a33710e9e8e95416e1/bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef", size = 481862, upload-time = "2025-09-25T19:49:28.365Z" }, + { url = "https://files.pythonhosted.org/packages/67/49/dd074d831f00e589537e07a0725cf0e220d1f0d5d8e85ad5bbff251c45aa/bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4", size = 268544, upload-time = "2025-09-25T19:49:30.39Z" }, + { url = "https://files.pythonhosted.org/packages/f5/91/50ccba088b8c474545b034a1424d05195d9fcbaaf802ab8bfe2be5a4e0d7/bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf", size = 271787, upload-time = "2025-09-25T19:49:32.144Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e7/d7dba133e02abcda3b52087a7eea8c0d4f64d3e593b4fffc10c31b7061f3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da", size = 269753, upload-time = "2025-09-25T19:49:33.885Z" }, + { url = "https://files.pythonhosted.org/packages/33/fc/5b145673c4b8d01018307b5c2c1fc87a6f5a436f0ad56607aee389de8ee3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9", size = 289587, upload-time = "2025-09-25T19:49:35.144Z" }, + { url = "https://files.pythonhosted.org/packages/27/d7/1ff22703ec6d4f90e62f1a5654b8867ef96bafb8e8102c2288333e1a6ca6/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f", size = 272178, upload-time = "2025-09-25T19:49:36.793Z" }, + { url = "https://files.pythonhosted.org/packages/c8/88/815b6d558a1e4d40ece04a2f84865b0fef233513bd85fd0e40c294272d62/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493", size = 269295, upload-time = "2025-09-25T19:49:38.164Z" }, + { url = "https://files.pythonhosted.org/packages/51/8c/e0db387c79ab4931fc89827d37608c31cc57b6edc08ccd2386139028dc0d/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b", size = 271700, upload-time = "2025-09-25T19:49:39.917Z" }, + { url = "https://files.pythonhosted.org/packages/06/83/1570edddd150f572dbe9fc00f6203a89fc7d4226821f67328a85c330f239/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c", size = 334034, upload-time = "2025-09-25T19:49:41.227Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f2/ea64e51a65e56ae7a8a4ec236c2bfbdd4b23008abd50ac33fbb2d1d15424/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4", size = 352766, upload-time = "2025-09-25T19:49:43.08Z" }, + { url = "https://files.pythonhosted.org/packages/d7/d4/1a388d21ee66876f27d1a1f41287897d0c0f1712ef97d395d708ba93004c/bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e", size = 152449, upload-time = "2025-09-25T19:49:44.971Z" }, + { url = "https://files.pythonhosted.org/packages/3f/61/3291c2243ae0229e5bca5d19f4032cecad5dfb05a2557169d3a69dc0ba91/bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d", size = 149310, upload-time = "2025-09-25T19:49:46.162Z" }, + { url = "https://files.pythonhosted.org/packages/3e/89/4b01c52ae0c1a681d4021e5dd3e45b111a8fb47254a274fa9a378d8d834b/bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993", size = 143761, upload-time = "2025-09-25T19:49:47.345Z" }, + { url = "https://files.pythonhosted.org/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b", size = 494553, upload-time = "2025-09-25T19:49:49.006Z" }, + { url = "https://files.pythonhosted.org/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", size = 275009, upload-time = "2025-09-25T19:49:50.581Z" }, + { url = "https://files.pythonhosted.org/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", size = 278029, upload-time = "2025-09-25T19:49:52.533Z" }, + { url = "https://files.pythonhosted.org/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", size = 275907, upload-time = "2025-09-25T19:49:54.709Z" }, + { url = "https://files.pythonhosted.org/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", size = 296500, upload-time = "2025-09-25T19:49:56.013Z" }, + { url = "https://files.pythonhosted.org/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", size = 278412, upload-time = "2025-09-25T19:49:57.356Z" }, + { url = "https://files.pythonhosted.org/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", size = 275486, upload-time = "2025-09-25T19:49:59.116Z" }, + { url = "https://files.pythonhosted.org/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", size = 277940, upload-time = "2025-09-25T19:50:00.869Z" }, + { url = "https://files.pythonhosted.org/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", size = 310776, upload-time = "2025-09-25T19:50:02.393Z" }, + { url = "https://files.pythonhosted.org/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", size = 312922, upload-time = "2025-09-25T19:50:04.232Z" }, + { url = "https://files.pythonhosted.org/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", size = 341367, upload-time = "2025-09-25T19:50:05.559Z" }, + { url = "https://files.pythonhosted.org/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", size = 359187, upload-time = "2025-09-25T19:50:06.916Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", size = 153752, upload-time = "2025-09-25T19:50:08.515Z" }, + { url = "https://files.pythonhosted.org/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", size = 150881, upload-time = "2025-09-25T19:50:09.742Z" }, + { url = "https://files.pythonhosted.org/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", size = 144931, upload-time = "2025-09-25T19:50:11.016Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", size = 495313, upload-time = "2025-09-25T19:50:12.309Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", size = 275290, upload-time = "2025-09-25T19:50:13.673Z" }, + { url = "https://files.pythonhosted.org/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", size = 278253, upload-time = "2025-09-25T19:50:15.089Z" }, + { url = "https://files.pythonhosted.org/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", size = 276084, upload-time = "2025-09-25T19:50:16.699Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", size = 297185, upload-time = "2025-09-25T19:50:18.525Z" }, + { url = "https://files.pythonhosted.org/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", size = 278656, upload-time = "2025-09-25T19:50:19.809Z" }, + { url = "https://files.pythonhosted.org/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", size = 275662, upload-time = "2025-09-25T19:50:21.567Z" }, + { url = "https://files.pythonhosted.org/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", size = 278240, upload-time = "2025-09-25T19:50:23.305Z" }, + { url = "https://files.pythonhosted.org/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", size = 311152, upload-time = "2025-09-25T19:50:24.597Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", size = 313284, upload-time = "2025-09-25T19:50:26.268Z" }, + { url = "https://files.pythonhosted.org/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", size = 341643, upload-time = "2025-09-25T19:50:28.02Z" }, + { url = "https://files.pythonhosted.org/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", size = 359698, upload-time = "2025-09-25T19:50:31.347Z" }, + { url = "https://files.pythonhosted.org/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", size = 153725, upload-time = "2025-09-25T19:50:34.384Z" }, + { url = "https://files.pythonhosted.org/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", size = 150912, upload-time = "2025-09-25T19:50:35.69Z" }, + { url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" }, +] + +[[package]] +name = "certifi" +version = "2026.5.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "click" +version = "8.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" }, +] + +[[package]] +name = "cloudinary" +version = "1.44.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "six" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0c/1b/ae48dbbe3c2ff81b94816dd6645af2d609e135cea4fcd65b1a8a61672cea/cloudinary-1.44.2.tar.gz", hash = "sha256:bc9139c68f2ef6996ba62a5d0300e63c711d4f6564644c0c82aa31bf641ef3c7", size = 188389, upload-time = "2026-04-16T08:18:19.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/8b/0da00a22362c89ef53f9d64af29546d24aba8533ffdb7bbcb19631c7f636/cloudinary-1.44.2-py3-none-any.whl", hash = "sha256:6624d06014c33f50c0c9e740fde1dfe4579d03311025d8bb7183824b7443aa09", size = 147805, upload-time = "2026-04-16T08:18:18.84Z" }, +] + +[[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 = "cryptography" +version = "48.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/a9/db8f313fdcd85d767d4973515e1db101f9c71f95fced83233de224673757/cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920", size = 832984, upload-time = "2026-05-04T22:59:38.133Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/3d/01f6dd9190170a5a241e0e98c2d04be3664a9e6f5b9b872cde63aff1c3dd/cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6", size = 8001587, upload-time = "2026-05-04T22:57:36.803Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6e/e90527eef33f309beb811cf7c982c3aeffcce8e3edb178baa4ca3ae4a6fa/cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c", size = 4690433, upload-time = "2026-05-04T22:57:40.373Z" }, + { url = "https://files.pythonhosted.org/packages/90/04/673510ed51ddff56575f306cf1617d80411ee76831ccd3097599140efdfe/cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3", size = 4710620, upload-time = "2026-05-04T22:57:42.935Z" }, + { url = "https://files.pythonhosted.org/packages/14/d5/e9c4ef932c8d800490c34d8bd589d64a31d5890e27ec9e9ad532be893294/cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5", size = 4696283, upload-time = "2026-05-04T22:57:45.294Z" }, + { url = "https://files.pythonhosted.org/packages/0c/29/174b9dfb60b12d59ecfc6cfa04bc88c21b42a54f01b8aae09bb6e51e4c7f/cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c", size = 5296573, upload-time = "2026-05-04T22:57:47.933Z" }, + { url = "https://files.pythonhosted.org/packages/95/38/0d29a6fd7d0d1373f0c0c88a04ba20e359b257753ac497564cd660fc1d55/cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f", size = 4743677, upload-time = "2026-05-04T22:57:50.067Z" }, + { url = "https://files.pythonhosted.org/packages/30/be/eef653013d5c63b6a490529e0316f9ac14a37602965d4903efed1399f32b/cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25", size = 4330808, upload-time = "2026-05-04T22:57:52.301Z" }, + { url = "https://files.pythonhosted.org/packages/84/9e/500463e87abb7a0a0f9f256ec21123ecde0a7b5541a15e840ea54551fd81/cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602", size = 4695941, upload-time = "2026-05-04T22:57:54.603Z" }, + { url = "https://files.pythonhosted.org/packages/e3/dc/7303087450c2ec9e7fbb750e17c2abfbc658f23cbd0e54009509b7cc4091/cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c", size = 5252579, upload-time = "2026-05-04T22:57:57.207Z" }, + { url = "https://files.pythonhosted.org/packages/d0/c0/7101d3b7215edcdc90c45da544961fd8ed2d6448f77577460fa75a8443f7/cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5", size = 4743326, upload-time = "2026-05-04T22:57:59.535Z" }, + { url = "https://files.pythonhosted.org/packages/ac/d8/5b833bad13016f562ab9d063d68199a4bd121d18458e439515601d3357ec/cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321", size = 4826672, upload-time = "2026-05-04T22:58:01.996Z" }, + { url = "https://files.pythonhosted.org/packages/98/e1/7074eb8bf3c135558c73fc2bcf0f5633f912e6fb87e868a55c454080ef09/cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74", size = 4972574, upload-time = "2026-05-04T22:58:03.968Z" }, + { url = "https://files.pythonhosted.org/packages/04/70/e5a1b41d325f797f39427aa44ef8baf0be500065ab6d8e10369d850d4a4f/cryptography-48.0.0-cp311-abi3-win32.whl", hash = "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4", size = 3294868, upload-time = "2026-05-04T22:58:06.467Z" }, + { url = "https://files.pythonhosted.org/packages/f4/ac/8ac51b4a5fc5932eb7ee5c517ba7dc8cd834f0048962b6b352f00f41ebf9/cryptography-48.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7", size = 3817107, upload-time = "2026-05-04T22:58:08.845Z" }, + { url = "https://files.pythonhosted.org/packages/6b/84/70e3feea9feea87fd7cbe77efb2712ae1e3e6edf10749dc6e95f4e60e455/cryptography-48.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec", size = 7986556, upload-time = "2026-05-04T22:58:11.172Z" }, + { url = "https://files.pythonhosted.org/packages/89/6e/18e07a618bb5442ba10cf4df16e99c071365528aa570dfcb8c02e25a303b/cryptography-48.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18", size = 4684776, upload-time = "2026-05-04T22:58:13.712Z" }, + { url = "https://files.pythonhosted.org/packages/be/6a/4ea3b4c6c6759794d5ee2103c304a5076dc4b19ae1f9fe47dba439e159e9/cryptography-48.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20", size = 4698121, upload-time = "2026-05-04T22:58:16.448Z" }, + { url = "https://files.pythonhosted.org/packages/2f/59/6ff6ad6cae03bb887da2a5860b2c9805f8dac969ef01ce563336c49bd1d1/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff", size = 4690042, upload-time = "2026-05-04T22:58:18.544Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b4/fc334ed8cfd705aca282fe4d8f5ae64a8e0f74932e9feecb344610cf6e4d/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c", size = 5282526, upload-time = "2026-05-04T22:58:20.75Z" }, + { url = "https://files.pythonhosted.org/packages/11/08/9f8c5386cc4cd90d8255c7cdd0f5baf459a08502a09de30dc51f553d38dc/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db", size = 4733116, upload-time = "2026-05-04T22:58:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/b8/77/99307d7574045699f8805aa500fa0fb83422d115b5400a064ddd306d7750/cryptography-48.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741", size = 4316030, upload-time = "2026-05-04T22:58:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/fd/36/a608b98337af3cb2aff4818e406649d30572b7031918b04c87d979495348/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166", size = 4689640, upload-time = "2026-05-04T22:58:27.747Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a6/825010a291b4438aecc1f568bc428189fc1175515223632477c07dc0a6df/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336", size = 5237657, upload-time = "2026-05-04T22:58:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/b9/09/4e76a09b4caa29aad535ddc806f5d4c5d01885bd978bd984fbc6ca032cae/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057", size = 4732362, upload-time = "2026-05-04T22:58:32.009Z" }, + { url = "https://files.pythonhosted.org/packages/18/78/444fa04a77d0cb95f417dda20d450e13c56ba8e5220fc892a1658f44f882/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae", size = 4819580, upload-time = "2026-05-04T22:58:34.254Z" }, + { url = "https://files.pythonhosted.org/packages/38/85/ea67067c70a1fd4be2c63d35eeed82658023021affccc7b17705f8527dd2/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c", size = 4963283, upload-time = "2026-05-04T22:58:36.376Z" }, + { url = "https://files.pythonhosted.org/packages/75/54/cc6d0f3deac3e81c7f847e8a189a12b6cdd65059b43dad25d4316abd849a/cryptography-48.0.0-cp314-cp314t-win32.whl", hash = "sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f", size = 3270954, upload-time = "2026-05-04T22:58:38.791Z" }, + { url = "https://files.pythonhosted.org/packages/49/67/cc947e288c0758a4e5473d1dcb743037ab7785541265a969240b8885441a/cryptography-48.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12", size = 3797313, upload-time = "2026-05-04T22:58:40.746Z" }, + { url = "https://files.pythonhosted.org/packages/f2/63/61d4a4e1c6b6bab6ce1e213cd36a24c415d90e76d78c5eb8577c5541d2e8/cryptography-48.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86", size = 7983482, upload-time = "2026-05-04T22:58:43.769Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ac/f5b5995b87770c693e2596559ffafe195b4033a57f14a82268a2842953f3/cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e", size = 4683266, upload-time = "2026-05-04T22:58:46.064Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c6/8b14f67e18338fbc4adb76f66c001f5c3610b3e2d1837f268f47a347dbbb/cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f", size = 4696228, upload-time = "2026-05-04T22:58:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/ea/73/f808fbae9514bd91b47875b003f13e284c8c6bdfd904b7944e803937eec1/cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7", size = 4689097, upload-time = "2026-05-04T22:58:50.9Z" }, + { url = "https://files.pythonhosted.org/packages/93/01/d86632d7d28db8ae83221995752eeb6639ffb374c2d22955648cf8d52797/cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832", size = 5283582, upload-time = "2026-05-04T22:58:53.017Z" }, + { url = "https://files.pythonhosted.org/packages/02/e1/50edc7a50334807cc4791fc4a0ce7468b4a1416d9138eab358bfc9a3d70b/cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c", size = 4730479, upload-time = "2026-05-04T22:58:55.611Z" }, + { url = "https://files.pythonhosted.org/packages/6f/af/99a582b1b1641ff5911ac559beb45097cf79efd4ead4657f578ef1af2d47/cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a", size = 4326481, upload-time = "2026-05-04T22:58:57.607Z" }, + { url = "https://files.pythonhosted.org/packages/90/ee/89aa26a06ef0a7d7611788ffd571a7c50e368cc6a4d5eef8b4884e866edb/cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a", size = 4688713, upload-time = "2026-05-04T22:59:00.077Z" }, + { url = "https://files.pythonhosted.org/packages/70/ba/bcb1b0bb7a33d4c7c0c4d4c7874b4a62ae4f56113a5f4baefa362dfb1f0f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a", size = 5238165, upload-time = "2026-05-04T22:59:02.317Z" }, + { url = "https://files.pythonhosted.org/packages/c9/70/ca4003b1ce5ca3dc3186ada51908c8a9b9ff7d5cab83cc0d43ee14ec144f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239", size = 4729947, upload-time = "2026-05-04T22:59:05.255Z" }, + { url = "https://files.pythonhosted.org/packages/44/a0/4ec7cf774207905aef1a8d11c3750d5a1db805eb380ee4e16df317870128/cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c", size = 4822059, upload-time = "2026-05-04T22:59:07.802Z" }, + { url = "https://files.pythonhosted.org/packages/1e/75/a2e55f99c16fcac7b5d6c1eb19ad8e00799854d6be5ca845f9259eae1681/cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4", size = 4960575, upload-time = "2026-05-04T22:59:09.851Z" }, + { url = "https://files.pythonhosted.org/packages/b8/23/6e6f32143ab5d8b36ca848a502c4bcd477ae75b9e1677e3530d669062578/cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd", size = 3279117, upload-time = "2026-05-04T22:59:12.019Z" }, + { url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" }, +] + +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, +] + +[[package]] +name = "ecdsa" +version = "0.19.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/25/ca/8de7744cb3bc966c85430ca2d0fcaeea872507c6a4cf6e007f7fe269ed9d/ecdsa-0.19.2.tar.gz", hash = "sha256:62635b0ac1ca2e027f82122b5b81cb706edc38cd91c63dda28e4f3455a2bf930", size = 202432, upload-time = "2026-03-26T09:58:17.675Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/79/119091c98e2bf49e24ed9f3ae69f816d715d2904aefa6a2baa039a2ba0b0/ecdsa-0.19.2-py2.py3-none-any.whl", hash = "sha256:840f5dc5e375c68f36c1a7a5b9caad28f95daa65185c9253c0c08dd952bb7399", size = 150818, upload-time = "2026-03-26T09:58:15.808Z" }, +] + +[[package]] +name = "email-validator" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, +] + +[[package]] +name = "fastapi" +version = "0.136.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/2d/ff8d91d7b564d464629a0fd50a4489c97fcb836ac230bf3a7269232a9b1f/fastapi-0.136.3.tar.gz", hash = "sha256:e487fae93ad408e6f47641ee4dfe389864fd7bec92e547ea8498fc13f43e83ab", size = 396410, upload-time = "2026-05-23T18:53:15.192Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/82/45359b62a067409bd929ae8a56b8ed13e5a8c8a61194b3c236920999ab83/fastapi-0.136.3-py3-none-any.whl", hash = "sha256:3d2a69bdf04b7e9f3afa292c3bc7a98816bbfafa10bc9b45f3f3700d2f761620", size = 117481, upload-time = "2026-05-23T18:53:16.924Z" }, +] + +[[package]] +name = "greenlet" +version = "3.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/6e/802acd792aebb2256fbbee8cacf2727faaeb6f240ac11008f09eae4414bc/greenlet-3.5.1.tar.gz", hash = "sha256:5a56aeb7d5d9cc4b3a735efb5095bd4b4f6f0e4f93e5ca876d0e2315137b7829", size = 197356, upload-time = "2026-05-20T15:05:03.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/37/4549f149c9797c21b32c2683c33522af22522099de128b2406672526d005/greenlet-3.5.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:fa4f98af3a528f0c3fd592a26df7f376f93329c8f4d987f6bb979057af8bf5e2", size = 286220, upload-time = "2026-05-20T13:07:28.463Z" }, + { url = "https://files.pythonhosted.org/packages/38/ff/a4f436709716965eaab9f36ea7b906c8a927fbe32fb1372a2071d964f6b1/greenlet-3.5.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffea73584b216150eab159b6d12348fb253e68757974de1e2c40d8a318ac89ed", size = 601585, upload-time = "2026-05-20T14:00:06.141Z" }, + { url = "https://files.pythonhosted.org/packages/65/ad/54bc3fcee3ad368a61b19b67d88117f7a8c29727bf71fffdeda81fbd946e/greenlet-3.5.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1072b4f9edcc1e192d9283a66a3e68d6b84c561de33a83d7858beb9ba1effe10", size = 614215, upload-time = "2026-05-20T14:05:42.675Z" }, + { url = "https://files.pythonhosted.org/packages/40/69/b91cda0647df839483201545913514c2827ebea5e5ccdf931842763bc127/greenlet-3.5.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:add5217d68b31130f0beca584d7fef4878327d2e31642b66618a14eef312b63b", size = 611358, upload-time = "2026-05-20T13:14:26.37Z" }, + { url = "https://files.pythonhosted.org/packages/59/90/3cf77e080350cd02fa307bb2abf05df48f4482c240275bbd2c203ba8bb1c/greenlet-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a5ea42a752d47a145eae922b605cd1634665ac3d5ec1e72402d5048e8d60d207", size = 1570475, upload-time = "2026-05-20T14:02:25.29Z" }, + { url = "https://files.pythonhosted.org/packages/65/2c/18cece62045e74598c3c393f70dce4a63f56222015ba29a5d4eeb04f764c/greenlet-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c5551170cf4f5ff5623e9af81323751979fee2c731e2287b61f73cd27257b823", size = 1635625, upload-time = "2026-05-20T13:14:34.027Z" }, + { url = "https://files.pythonhosted.org/packages/30/f5/310d104ddf41eb5a70f4c268d22508dfb0c3c8e86fec152be34d0d2ed819/greenlet-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3c8bb982ad117d29478ef8f5533e97df21f1e2befd17a299257b0c96d1371c0b", size = 238791, upload-time = "2026-05-20T13:10:39.018Z" }, + { url = "https://files.pythonhosted.org/packages/62/90/ceca11f504cd23a8047a3dea31919adc48df9b626dd0c13f0d858734fdfd/greenlet-3.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:80eb4b04dadc4e67df3fae179a32c4706a3f495bc7f22fc8a81115d5f5512188", size = 235580, upload-time = "2026-05-20T13:08:45.056Z" }, + { url = "https://files.pythonhosted.org/packages/27/69/7f7e5372d998b81001899b1c0823c957aa413ba0f2662e65821611cc31e4/greenlet-3.5.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:51518ff74664078fc51bffcc6fc529b0df5ae58da192691cee765d45ce944a2b", size = 285060, upload-time = "2026-05-20T13:08:51.899Z" }, + { url = "https://files.pythonhosted.org/packages/b1/bf/387f9b6b865fd2ae0d0be09e0004827295a01b71be76ed350dd1e28a91a4/greenlet-3.5.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ffdb3c0bb002c99cd8f298957e046c3dbf6006b5b7cdf11a4e19194624a0a0a", size = 604370, upload-time = "2026-05-20T14:00:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/32/f5/169ce3d4e4c67291bd18f8cbe0299c9f3e45102c7f1fb3c14780c93e4532/greenlet-3.5.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7715a5a2c3378ba602c3a440558261e13a820bb53a82693aacd7b7f6d964e283", size = 616987, upload-time = "2026-05-20T14:05:44.237Z" }, + { url = "https://files.pythonhosted.org/packages/ee/e5/7f2e41d5273be07e77560d61ea4e56485b4d6c316d2a84518c62d1364061/greenlet-3.5.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc71ff466927a201b08305acac451ebe1aedfcea002f62f1f2f2ac2ac1e6a135", size = 613911, upload-time = "2026-05-20T13:14:27.539Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a4/fbdc67579b73615a1f91615e814303cc71e06128f7baaba87be79b8fb90c/greenlet-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cd443683db272ebaaca03af98c0b063ab30db70ea8a31a1559f35e3f7b744ccd", size = 1570689, upload-time = "2026-05-20T14:02:27.225Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b4/77abbe35078be39718a46cd49caf16bceb35662f97a34101dca28aa98e47/greenlet-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:089fff7a6ce8d9316d1f65ebc00273a56be258c1725b32b94de90a3a979557e1", size = 1635602, upload-time = "2026-05-20T13:14:36.344Z" }, + { url = "https://files.pythonhosted.org/packages/37/f7/129f27ca700845b8ee8ca88ce7f43435a1239c2eddb7677fc938822762cf/greenlet-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:110a1ca7b49b014b097f6078272c3f4ed31af45b254de5228b79adba879f6af9", size = 238683, upload-time = "2026-05-20T13:11:50.57Z" }, + { url = "https://files.pythonhosted.org/packages/6d/5c/a485a36e87df8d8fd0632ee01511244f5156a20ed3746cc6599340326395/greenlet-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:f16ba1efc0715b680a18b8123d90dad887c6112ae3555b4b5c32c149540c6b4e", size = 235499, upload-time = "2026-05-20T13:12:42.028Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cb/c62454606daf5640369c94d8a9dd540599b1bfc090e2d2180cb77f4038d2/greenlet-3.5.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8ab31c9de8651a2facdd5c5bb0011f2380dd1a7af78ce2adf4b56095294fc07", size = 285579, upload-time = "2026-05-20T13:08:56.396Z" }, + { url = "https://files.pythonhosted.org/packages/ec/71/c4270398c2eba968a6071af1dfbdcaeee6ec1c24bc8b435b8cc452700da6/greenlet-3.5.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e300185139abc337ade480c327183adf42a875ac7181bfe66d7d4efea31fbea", size = 651106, upload-time = "2026-05-20T14:00:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/1a/ab/71e34b78a44ec271fb5f550c17bc46d301ddc5953890d935f270b0dcdb5a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7ffdb990dcaa0234cf9845aead5df2e3c3a8b6507d409274dd87e0d5ab05ffc2", size = 663478, upload-time = "2026-05-20T14:05:45.88Z" }, + { url = "https://files.pythonhosted.org/packages/77/96/4efd6fa5c62c85426a0c19077a586258ebc3a2a146ff2493e4312a697a22/greenlet-3.5.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f82b3597e9d83b63408affed0b48fd0f54935edac4302237b9a837be0dae33c", size = 660800, upload-time = "2026-05-20T13:14:29.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/e0/6c71401a25cac7000261304e866a2f2cc04dc74810d40e2f118aa4799495/greenlet-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c0141e37414c10164e702b8fb1473304221ad98f71600850c6ef7ff4880feba0", size = 1617518, upload-time = "2026-05-20T14:02:28.662Z" }, + { url = "https://files.pythonhosted.org/packages/41/26/c5c06643e8c0af9e7bf18e16cb51d0ab7625155f0392e1c9015d66d556cd/greenlet-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:50ae25a67bea74ea41fb14b960bc532df73eb713417b2d61892dced82fe8d3bc", size = 1681593, upload-time = "2026-05-20T13:14:39.417Z" }, + { url = "https://files.pythonhosted.org/packages/8a/bd/e11a108317485075e68af9d23039619b86b28130c3b50d227d42edece64b/greenlet-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:8a17c42330e261299766b75ac1ea32caa437a9453c8f65d16a13140db378ecd3", size = 239800, upload-time = "2026-05-20T13:09:30.128Z" }, + { url = "https://files.pythonhosted.org/packages/47/f8/8e8e8417b7bf28639a5a56356ef934d0375e1d0c70a57e04d7701e870ffe/greenlet-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:7b5f5fae05b8ac6d176a61b60c394a8cbdc2b5b91b81793066e68745cf165e54", size = 236862, upload-time = "2026-05-20T13:09:10.498Z" }, + { url = "https://files.pythonhosted.org/packages/90/12/41bf27fde4d3605d3773ae57751eda182b8be2f5398011c041173b1d9534/greenlet-3.5.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:ea8da1e900d758d078810d4255d8c6aa572181896a31ec79d779eb79c3adc9ad", size = 293637, upload-time = "2026-05-20T13:12:35.529Z" }, + { url = "https://files.pythonhosted.org/packages/44/44/ba14b23e9757707050c2f397d305bbcae62e5d7cad122f8b6baec5ae4a1f/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a19570c52a21420dcbc94e661994bc325c0b5b11304540fed514586da5dc8f2e", size = 650840, upload-time = "2026-05-20T14:00:11.079Z" }, + { url = "https://files.pythonhosted.org/packages/a8/37/5ddc2b686a6844f91abecef43411842426da2e1573f60b49ecf2547f4ae1/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3d955c89b75eeca4723d7cc14135f393cd47c32e2a6cb4a8e4c6e760a26b0986", size = 656416, upload-time = "2026-05-20T14:05:47.118Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f0/d17510297c35a2992712f0bf84de3779749999f7d3d63aa1f09db7c62dbe/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2daaaebd1a5aa88c49045b6baf9310b3263796bd88db713edf37cf53e7bb4e", size = 654397, upload-time = "2026-05-20T13:14:30.696Z" }, + { url = "https://files.pythonhosted.org/packages/37/eb/147387705bb89092645b012586e7273cb5ed3c90ef7eaf3a69173eaf0209/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bfbd69cc349e43bf3a8ae1c85548ff0718efc887615c2db16c3833d7b0b072d", size = 1614469, upload-time = "2026-05-20T14:02:30.192Z" }, + { url = "https://files.pythonhosted.org/packages/a6/4e/37ee0da7732b7aa9896f17e15579a9df34b9fcb9dd494f0adfa749af6623/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4378720dd888136c27215a0214d32a4d37c3852765d45bc37aad0623423cfd78", size = 1675115, upload-time = "2026-05-20T13:14:40.972Z" }, + { url = "https://files.pythonhosted.org/packages/57/f3/97dfcf4a6eb5077f8a672234216fb5923eb89f2cab7081cb10b2cf75b605/greenlet-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:45718441607f9325d948db98cbc691276059316d0358c188c246da4e1d4d23d2", size = 245246, upload-time = "2026-05-20T13:12:22.646Z" }, + { url = "https://files.pythonhosted.org/packages/5d/73/d7f72e34b582f694f4a9b248162db7b09cc458a259ba8f0c0bfa1a34ea7d/greenlet-3.5.1-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:2baee5ca02031757ffe8cc3d69f0cc0aec7065ce362622da74f32d3bcab1c541", size = 285575, upload-time = "2026-05-20T13:12:07.043Z" }, + { url = "https://files.pythonhosted.org/packages/df/59/fa9c6e87dc8ad27a95dabe2f29f372b733d05a8a67470f6c901ed9975655/greenlet-3.5.1-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b1ec3274918a81d3ea778b9e75b56b72b33f300edb6cf7f3a7fe1dae56683de", size = 656428, upload-time = "2026-05-20T14:00:12.556Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f9/e753408871eaa61dfe35e619cfc67512b036fde99893685d50eea9e07146/greenlet-3.5.1-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:111e2390ffffc47d5840b01711dd7fac07d4c09283d0283e7f3264b14e284c64", size = 667064, upload-time = "2026-05-20T14:05:48.662Z" }, + { url = "https://files.pythonhosted.org/packages/96/27/5565b5b40389f1c7753003a07e21892fda8660926787036d5bc0308b8113/greenlet-3.5.1-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e630136e905fe5ff43e86945ae41220b6d1470956a39220e708110ac48d01ea5", size = 665697, upload-time = "2026-05-20T13:14:32.943Z" }, + { url = "https://files.pythonhosted.org/packages/cf/82/e7de4178c0c2d1c9a5a3be3cc0b33e46a85b3ee4a77c071bf7ad8600e079/greenlet-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:975eac34b44a7077ca4d421348455b94f0f518246a7f14bc6d2fdcfe5b584368", size = 1621256, upload-time = "2026-05-20T14:02:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/00/10/f2dddcf7dacac17dfc68691809589adad06135eb28930429cf58a6467a2f/greenlet-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:9ab3c3a0b2ae6198e67c898dad5215a49f9ae0d0081b3c3ec59f333e39eeca26", size = 1685956, upload-time = "2026-05-20T13:14:42.55Z" }, + { url = "https://files.pythonhosted.org/packages/22/17/4a232b32133230ada52f70e9d7f5b65b0caef8772f01849bd8d149e7e4ca/greenlet-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:cbfc69be86e10dcfef5b1e6269d1d6926552aa89ee39e1de3353360c1b6989ab", size = 239802, upload-time = "2026-05-20T13:13:15.481Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ae/4e623a7e6d4d2a5f4cb8e4c82de4169fc637942caae68d6e676b8a128ac5/greenlet-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:92fd6d44ac5e5a887c8a5dc4a8ba0ba908527c31c12f78c6bc7dcfe8aab279f6", size = 236853, upload-time = "2026-05-20T13:15:37.301Z" }, + { url = "https://files.pythonhosted.org/packages/7a/57/816d9cff29119da3505b3d6a5e14a8af89006ac36f47f891ff293ee05af1/greenlet-3.5.1-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:a6fdf2433a5441ef9a95464f7c3e674775da1c8c1177fff311cee1acad4626ed", size = 293877, upload-time = "2026-05-20T13:10:19.078Z" }, + { url = "https://files.pythonhosted.org/packages/23/a1/59b0a7c7d140ff1a75626680b9a9899b79a9176cab298b394968fb023295/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7546556f0d649f99f6a361098a55f761181bb2ea12ff150bb16d26092ad88244", size = 655333, upload-time = "2026-05-20T14:00:14.758Z" }, + { url = "https://files.pythonhosted.org/packages/72/1b/5efe127597625042218939d01855109f352779050768b670b52edcc16a6c/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5ee3ea898009fa898f85f9982255d35278c477bebe185beca249cab42d4526c", size = 659443, upload-time = "2026-05-20T14:05:50.159Z" }, + { url = "https://files.pythonhosted.org/packages/6c/6d/c404246ea4d22d097a7426d0efb5b781bd7eb67715f09e79001bd552ab18/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5c81f74d204d3edd136ebfd50dce53acbb776995d721a0fe801626cfc93b8cd", size = 658356, upload-time = "2026-05-20T13:14:35.091Z" }, + { url = "https://files.pythonhosted.org/packages/51/02/f8ee37fb6d2219329f350af241c27fcf12df57e723d11f6fc6d3bacdadaa/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:2c18ef16bf6d4dd410e4dd52996888ea1497be26892fe5bbc73580aba4287b8e", size = 1619216, upload-time = "2026-05-20T14:02:33.403Z" }, + { url = "https://files.pythonhosted.org/packages/93/c5/3dc9475ace2c7a3680da12372cddd7f1ac874eb410a1ac48d3e9dab83782/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:17d86354f0ae6b61bf9be5148d0dd34e06c3cb7c602c671f79f29ac3b150e659", size = 1678427, upload-time = "2026-05-20T13:14:43.71Z" }, + { url = "https://files.pythonhosted.org/packages/df/4e/750c15c317a41ffb36f0bf40b933e3d744a7dede61889f74443ea69690cf/greenlet-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:e7516cf6ae6b8a582c2770a0caed47b8a48373ed732c33d69a72913ae6ac923e", size = 245225, upload-time = "2026-05-20T13:13:59.366Z" }, + { url = "https://files.pythonhosted.org/packages/4f/fd/d3baea2eeb7b617efd47e87ca06e2ec2c6118d303aa9e918e0ce16eadc10/greenlet-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:5028648bf2253ec4745add746129d3904121fa7fe871a76bed23c5720573ce0a", size = 239590, upload-time = "2026-05-20T13:13:37.382Z" }, +] + +[[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/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d", size = 208247, upload-time = "2026-05-25T22:17:07.843Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5", size = 113064, upload-time = "2026-05-25T22:17:09.136Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2", size = 523851, upload-time = "2026-05-25T22:17:10.106Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09", size = 518842, upload-time = "2026-05-25T22:17:11.218Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a", size = 501238, upload-time = "2026-05-25T22:17:12.728Z" }, + { url = "https://files.pythonhosted.org/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745", size = 509567, upload-time = "2026-05-25T22:17:13.842Z" }, + { url = "https://files.pythonhosted.org/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918, upload-time = "2026-05-25T22:17:15.155Z" }, + { 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.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[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.3.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/62/791b31e69ae182791ec67f04850f2f062716bbd205483d63a215f3e062d3/mako-1.3.12.tar.gz", hash = "sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a", size = 400219, upload-time = "2026-04-28T19:01:08.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/b1/a0ec7a5a9db730a08daef1fdfb8090435b82465abbf758a596f0ea88727e/mako-1.3.12-py3-none-any.whl", hash = "sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9", size = 78521, upload-time = "2026-04-28T19:01:10.393Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "numpy" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/05/3d27272d30698dc0ecb7fdfaa41ad70303b444f81722bb99bce1d818638a/numpy-2.5.0.tar.gz", hash = "sha256:5a129578019311b6e56bdd714250f19b518f7dceeeb8d1af5490f4942d3f891c", size = 20652461, upload-time = "2026-06-21T20:57:51.95Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/0a/11486d02add7b1384dff7374d124b1cfbb0ee864dcc9f6a2c0380638cf84/numpy-2.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:489780423903667933b4ed6197b6ec3b75ea5dd17d1d8f0f38d798feb6921561", size = 16789987, upload-time = "2026-06-21T20:56:16.657Z" }, + { url = "https://files.pythonhosted.org/packages/55/b2/285f48640a181947b4587a3766d21ec1eaa7fea833d4b49957e09da467a2/numpy-2.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ece55976ced6bca95a03ae2839e2e5ccffe8eb6a3e7022415645eb154a81e4e6", size = 11760322, upload-time = "2026-06-21T20:56:19.813Z" }, + { url = "https://files.pythonhosted.org/packages/dd/67/b032db1eb03ca30d16eda3b0c22aaa615338b9263c2fd559d0f29451aca4/numpy-2.5.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:c83b664b0e6eee9594fa920cf0639d8af796606d3fad6cc70180c87e4b97c7be", size = 5319605, upload-time = "2026-06-21T20:56:22.173Z" }, + { url = "https://files.pythonhosted.org/packages/b9/83/03fc7300c7c6b6c84c487b1dc80d322817b95fbd1f4dd57a85e23b7198de/numpy-2.5.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:bf80333980bf37f523341ddd72c783f39d6829ec7736b9eb99086388a2d52cc2", size = 6653628, upload-time = "2026-06-21T20:56:23.914Z" }, + { url = "https://files.pythonhosted.org/packages/82/49/2ec21730bc63ccfda829323f7040a8ed4715b3852ce658689cf74ee96a8c/numpy-2.5.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1a4874217b36d5ac8fc876f52e39df56f8182c88463e9e2dceabf7ca8b7efb8", size = 15153691, upload-time = "2026-06-21T20:56:25.631Z" }, + { url = "https://files.pythonhosted.org/packages/bb/6b/f4a3d0637692c49da8ef99d72d52526f92e0a8d6ac4f0ca9f31441b9d9ea/numpy-2.5.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aaa760137137e8d3c920d27927748215b56014f92667dc9b6c27dfc61249255a", size = 16660066, upload-time = "2026-06-21T20:56:28.009Z" }, + { url = "https://files.pythonhosted.org/packages/3a/2f/c354ec86d1f3f5c19649463b0d39652e160736e5b0a4cd18dff0576715c4/numpy-2.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7174ce8265fc7f7417d171c9ea8fe905220748893ea67a2a7abe726ec331c4b0", size = 16514638, upload-time = "2026-06-21T20:56:30.26Z" }, + { url = "https://files.pythonhosted.org/packages/06/34/43efdcb319988648580f93c11f1ae82cf7e2faa74925e98e454ae3aa95f8/numpy-2.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b8c3daaf99de52415d20b42f8e8155c78642cb04207d02f9d317a0dcf1b3fb54", size = 18419647, upload-time = "2026-06-21T20:56:32.41Z" }, + { url = "https://files.pythonhosted.org/packages/71/e2/f5d1676b1d7fb682eb5e9a1641e7ebd2414b3216c370661d1029778908b4/numpy-2.5.0-cp312-cp312-win32.whl", hash = "sha256:6206db0af545d73d068add6d992279145f158428d1da6cc49adc4b630c5d6ee5", size = 6056688, upload-time = "2026-06-21T20:56:34.657Z" }, + { url = "https://files.pythonhosted.org/packages/8f/7c/48f115d1c58a34032facebcd51fdf2d02df2c51d4a46a81dd1197bb2ea6b/numpy-2.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:6f2d6873e2940c860a309d21e25b1e69af6aaffdd80aa056b04c16380db1c4f2", size = 12419237, upload-time = "2026-06-21T20:56:36.24Z" }, + { url = "https://files.pythonhosted.org/packages/86/26/2e0882f4044d1b1a1b63e875151fb2393389032022a8b7f5657a7996d3b2/numpy-2.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:a55e1eb2bca2cfd17a16b213c99dfc8502d47b0d494224d2122277d0400935ca", size = 10339912, upload-time = "2026-06-21T20:56:38.733Z" }, + { url = "https://files.pythonhosted.org/packages/8a/33/07675aaad7f26ea013d5e884d9a0d784b79c6bd7566c333f5a52fa3c610b/numpy-2.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:520e6b8be0a4b65840ac8090d4f51cef4bed66e2b0894d5a520f099adc24a9b2", size = 16784890, upload-time = "2026-06-21T20:56:40.799Z" }, + { url = "https://files.pythonhosted.org/packages/85/4b/953118a730ee3b35e28645e0eb4cf9beec5bdbb954e1ac2f5fcefba6bbc3/numpy-2.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:146b81cdd3967fdb6beca8ba25f00c58741d8f3cbd797f55af0fbe0bfec3469c", size = 11754584, upload-time = "2026-06-21T20:56:43.094Z" }, + { url = "https://files.pythonhosted.org/packages/44/9b/56dd530c367c74ae17411027cea4135ca57e1e0583bf5594cee18bd83217/numpy-2.5.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:126b88d95e8ff9b00c9e717aa540469f21d6180162f84c0caec51b16215d49cd", size = 5313904, upload-time = "2026-06-21T20:56:45.503Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b0/bcd672edad27ecca7da1f7bb0ce72cd1706a4f2d79ae94990afc97c13e1c/numpy-2.5.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d4313cef1594c5ce46c31b6e54e918338f63f16ee9322304e8c9114d6d81c8bd", size = 6648504, upload-time = "2026-06-21T20:56:47.567Z" }, + { url = "https://files.pythonhosted.org/packages/80/9e/15cdfcbd30a1544a46c9e487a00df331c4672450216538705a9e51fa6710/numpy-2.5.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:750fb097caf26fa878746d9d119f6f9da12dedcbff1eea966c3e3447647c4a9e", size = 15150086, upload-time = "2026-06-21T20:56:49.352Z" }, + { url = "https://files.pythonhosted.org/packages/32/4e/8d7656ccaab3e81e97258b8a9bc5f0c8502513a92fb4ceb0a2cbfebc17bf/numpy-2.5.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3893adc2dc7c0412ba76777db55a049215d99c9aa3113003be8f49f4f1290ab9", size = 16647250, upload-time = "2026-06-21T20:56:51.542Z" }, + { url = "https://files.pythonhosted.org/packages/3c/81/97060281b602ed07f21b12f4ec409eac1f75a2f91fbc829ed8b2becf3ad4/numpy-2.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:835e454dd99b238cdc5a3f63bce2371296f5ebc53ca1e0f8e6ddbb6d92a29aab", size = 16512864, upload-time = "2026-06-21T20:56:55.401Z" }, + { url = "https://files.pythonhosted.org/packages/33/ab/4496208146911f8d8ddb54f68a972aafa6c8d44babcb2ea03b0e5cc87c9d/numpy-2.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6f9836778081a0a3c02a6a21493f3e9f5b311f8d2541934f31f05583dc999ea4", size = 18408407, upload-time = "2026-06-21T20:56:57.75Z" }, + { url = "https://files.pythonhosted.org/packages/d4/9f/a4df67c181e4ee8b467aa3332dc2db10fd5c515136831302f3ca48bc0a01/numpy-2.5.0-cp313-cp313-win32.whl", hash = "sha256:0b525be4744b60bb0557ac872d53ef07d085b5f39622bc579c98d3809d05b988", size = 6054431, upload-time = "2026-06-21T20:57:00.016Z" }, + { url = "https://files.pythonhosted.org/packages/30/53/491e1c47c55b62ccc6a63c1c5b8635c73fc2258dddeb9bda27cae4a0ae96/numpy-2.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:44353e2878930039db472b99dc353d749826e4010bd4d2a7f835e94a97a5c748", size = 12414420, upload-time = "2026-06-21T20:57:01.815Z" }, + { url = "https://files.pythonhosted.org/packages/eb/4a/25c2906f541e9d9f4c5769764db732e6627be91a13f4724fa10634d77db4/numpy-2.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:48f54b00711f83a5f796b70c518e8c2b3c5848dda03a54911f23eb68519b9b60", size = 10339533, upload-time = "2026-06-21T20:57:03.961Z" }, + { url = "https://files.pythonhosted.org/packages/86/ad/abc44aaceaf7b17ee1edde2bbb4458da591bc79574cffff50c4bb35f00d1/numpy-2.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f27582c55ba4c750b7c58c8faf021d2cd9324a662b466229db8a417b41368af9", size = 16783807, upload-time = "2026-06-21T20:57:06.253Z" }, + { url = "https://files.pythonhosted.org/packages/5d/39/b72e168daf9c00fb20c9fc996d00437ccecdef3102387775d29d7a62576d/numpy-2.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:28e7137057d551e4a83c4ae414e3451f50568409db7569aacc7f9811ee06a446", size = 11765215, upload-time = "2026-06-21T20:57:08.547Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a0/8400a9c0e3625182347593f5e1f57da9a617a534794805c8df5518154ddc/numpy-2.5.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:e1da54b53e75cd9fcfc23efcc7edab2c6aecf97b6037566d8a0fe804af8ec57c", size = 5324493, upload-time = "2026-06-21T20:57:11.012Z" }, + { url = "https://files.pythonhosted.org/packages/f6/8c/0d104deaa0401c93395a629ec902891618a2eff76d19229139cb5a887bfc/numpy-2.5.0-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:694d8f74e156f7fd01179f1aa8faa2f648ab6ae0f70b6c3fe57a03249aea2303", size = 6645211, upload-time = "2026-06-21T20:57:12.919Z" }, + { url = "https://files.pythonhosted.org/packages/6a/d9/4a4a628c812750363786afc3d33492709a5cd64b215469c16b0f6c7bb811/numpy-2.5.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1a7569a7b53c77716f036bb28cb1c91f166a26ec7d9502cd1e4bdfe502fdec22", size = 15166004, upload-time = "2026-06-21T20:57:14.717Z" }, + { url = "https://files.pythonhosted.org/packages/a0/5e/2a902317d7fc4aa93236e80c932662dadfc459b323d758329e01775125e1/numpy-2.5.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:39a0433bd4086ebd462960cf375e19195bb07b53dc1d87dd5fcf47ad78576f03", size = 16650797, upload-time = "2026-06-21T20:57:16.906Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a0/a0090e6329f4ca5992c07847bb579c5259a19953dc57255bb08793142ffb/numpy-2.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:929f0c79ac38bcbd7154fe631dc907abfeddbcc5027a896bd1f7767323271e7a", size = 16524647, upload-time = "2026-06-21T20:57:19.165Z" }, + { url = "https://files.pythonhosted.org/packages/5e/7d/6caf27734c42b65837e7461ed0dbbd6b6fc835060c9714ec59d673bb383a/numpy-2.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cc4f247a47bbf070bfd70be53ccdcf47b800af563535e7bbe172322197c30e21", size = 18411841, upload-time = "2026-06-21T20:57:21.638Z" }, + { url = "https://files.pythonhosted.org/packages/13/dc/26edadbd812536769a82c2e9e002234e33feb5da43061d47a044f6d309b7/numpy-2.5.0-cp314-cp314-win32.whl", hash = "sha256:5dc71423499fab3f46f7a7201155ade1669ea101f2f429d332df9e72f8161731", size = 6106361, upload-time = "2026-06-21T20:57:23.844Z" }, + { url = "https://files.pythonhosted.org/packages/f2/9e/4dd1459282229a72d92dece2ae9138e5cac94a72263a7ceb48f37434c925/numpy-2.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:ebb81d9d5443e0309d6c54894c3fbed74ad7da0714352a67b6d773cd189eae73", size = 12551749, upload-time = "2026-06-21T20:57:25.945Z" }, + { url = "https://files.pythonhosted.org/packages/05/a7/6bc6384c080b86c7f6c85c5bc5b540b24f4f679cd144791d99574e90d462/numpy-2.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:3b94d0d0deceebfad3e67ae5c0e5eb87371e8f7a0581cd04a779928c2450cf1e", size = 10617072, upload-time = "2026-06-21T20:57:28.175Z" }, + { url = "https://files.pythonhosted.org/packages/86/6b/4a2b71d66ada5608ae02b63f150dfad520f6940721cb7f029ad270befc0e/numpy-2.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:22f3d43e362d650bc39db1f17851302874a148ca95ba6981c1dfb5fa6862f35b", size = 11881067, upload-time = "2026-06-21T20:57:30.104Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b2/d365eb40a20efb49d67e9feb90494ed8511282ee1f5fa16006675c65397d/numpy-2.5.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:243563efb4cd7528a264567e9fd206c87826457322521d06206a00bfa316c927", size = 5440290, upload-time = "2026-06-21T20:57:32.193Z" }, + { url = "https://files.pythonhosted.org/packages/fa/5e/e9c03188de5f9b767e46a8fe988bcfd3efad066a4a3fda8b9cb11a93f895/numpy-2.5.0-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:84881d825ca75249b189bbee875fcfe3238aa5c479e6100893cda566e8e86826", size = 6748371, upload-time = "2026-06-21T20:57:33.933Z" }, + { url = "https://files.pythonhosted.org/packages/fd/1d/68c186a38a5027bae2c4ddd5ea681fdaf8b4d30fb7301def6d8ad270390f/numpy-2.5.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cda12aa4779d42b8771180aba759c96f527d43446d8f380ab59e2b35e8489efd", size = 15214643, upload-time = "2026-06-21T20:57:35.677Z" }, + { url = "https://files.pythonhosted.org/packages/8c/67/73f67b7c7e20635baae9c4c3ead4ae7326a005900297a6110971abd62eb5/numpy-2.5.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c0121101093d2bd74981b10f8837d78e794a8ff57834eb27179f49e1ba11ac6", size = 16690128, upload-time = "2026-06-21T20:57:38.159Z" }, + { url = "https://files.pythonhosted.org/packages/eb/05/d4c1fb0c46d02a27d6b2b8b319a78c90937acec8631c1641874670b31e6f/numpy-2.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d371c92cfa09da00022f501ab67fafaea813d752eb30ac44336d45b1e5b0268a", size = 16577902, upload-time = "2026-06-21T20:57:40.447Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1d/771c797d50fa26e4888989cccf1d50ee51f530d4e455ad2692dcb64fa711/numpy-2.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9990713e9c38154c6861e7547f1e3fc7a87e75ff09bab24ef1cc81d81c2835e9", size = 18452814, upload-time = "2026-06-21T20:57:42.875Z" }, + { url = "https://files.pythonhosted.org/packages/e8/46/52fc0d2a68d7643f0f149eeea5a5d8ea2a3507056ac8afa83c9212606e8b/numpy-2.5.0-cp314-cp314t-win32.whl", hash = "sha256:edadfbd4794b1086c0d822f81863e8a68fc129d132fd0bb9e31e955d7fbbbdb7", size = 6253168, upload-time = "2026-06-21T20:57:45.101Z" }, + { url = "https://files.pythonhosted.org/packages/2a/be/6c8d1118b5f13b2881dc095d5b345de19c6638b8959c17409b6eff84c8aa/numpy-2.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f7e5fa4382967ae6548bd2f174219afb908e294b0d5f625af01166edd5f7d9aa", size = 12736286, upload-time = "2026-06-21T20:57:46.935Z" }, + { url = "https://files.pythonhosted.org/packages/fd/6a/d3a169aaf8536cf228d56a09e04bcb713a2fe4410d4e2105b9419b5a9c89/numpy-2.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:016623417bb330d719d579daf2d6b9a01ddc52e41a9ed61a47f39fde46dcd865", size = 10686451, upload-time = "2026-06-21T20:57:49.313Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "passlib" +version = "1.7.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/06/9da9ee59a67fae7761aab3ccc84fa4f3f33f125b370f1ccdb915bf967c11/passlib-1.7.4.tar.gz", hash = "sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04", size = 689844, upload-time = "2020-10-08T19:00:52.121Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/a4/ab6b7589382ca3df236e03faa71deac88cae040af60c071a78d254a62172/passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1", size = 525554, upload-time = "2020-10-08T19:00:49.856Z" }, +] + +[package.optional-dependencies] +bcrypt = [ + { name = "bcrypt" }, +] + +[[package]] +name = "pgvector" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/25/6c/6d8b4b03b958c02fa8687ec6063c49d952a189f8c91ebbe51e877dfab8f7/pgvector-0.4.2.tar.gz", hash = "sha256:322cac0c1dc5d41c9ecf782bd9991b7966685dee3a00bc873631391ed949513a", size = 31354, upload-time = "2025-12-05T01:07:17.87Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/26/6cee8a1ce8c43625ec561aff19df07f9776b7525d9002c86bceb3e0ac970/pgvector-0.4.2-py3-none-any.whl", hash = "sha256:549d45f7a18593783d5eec609ea1684a724ba8405c4cb182a0b2b08aeff04e08", size = 27441, upload-time = "2025-12-05T01:07:16.536Z" }, +] + +[[package]] +name = "pillow" +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" }, + { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" }, + { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" }, + { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" }, + { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" }, + { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" }, + { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, + { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, + { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, + { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, + { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, + { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, + { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, + { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, + { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, + { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, + { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, + { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, + { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" }, + { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" }, + { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" }, + { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" }, + { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" }, + { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" }, + { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" }, + { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" }, + { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" }, + { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" }, + { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" }, + { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, +] + +[[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 = "psycopg2-binary" +version = "2.9.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2a/60/a3624f79acea344c16fbef3a94d28b89a8042ddfb8f3e4ca83f538671409/psycopg2_binary-2.9.12.tar.gz", hash = "sha256:5ac9444edc768c02a6b6a591f070b8aae28ff3a99be57560ac996001580f294c", size = 379686, upload-time = "2026-04-21T09:40:34.304Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/9f/ef4ef3c8e15083df90ca35265cfd1a081a2f0cc07bb229c6314c6af817f4/psycopg2_binary-2.9.12-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5cdc05117180c5fa9c40eea8ea559ce64d73824c39d928b7da9fb5f6a9392433", size = 3712459, upload-time = "2026-04-20T23:34:30.549Z" }, + { url = "https://files.pythonhosted.org/packages/b5/01/3dd14e46ba48c1e1a6ec58ee599fa1b5efa00c246d5046cd903d0eeb1af1/psycopg2_binary-2.9.12-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d3227a3bc228c10d21011a99245edca923e4e8bf461857e869a507d9a41fe9f6", size = 3822936, upload-time = "2026-04-20T23:34:32.77Z" }, + { url = "https://files.pythonhosted.org/packages/a6/f7/0640e4901119d8a9f7a1784b927f494e2198e213ceb593753d1f2c8b1b30/psycopg2_binary-2.9.12-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:995ce929eede89db6254b50827e2b7fd61e50d11f0b116b29fffe4a2e53c4580", size = 4578676, upload-time = "2026-04-20T23:34:35.18Z" }, + { url = "https://files.pythonhosted.org/packages/b0/55/44df3965b5f297c50cc0b1b594a31c67d6127a9d133045b8a66611b14dfb/psycopg2_binary-2.9.12-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9fe06d93e72f1c048e731a2e3e7854a5bfaa58fc736068df90b352cefe66f03f", size = 4274917, upload-time = "2026-04-20T23:34:37.982Z" }, + { url = "https://files.pythonhosted.org/packages/b0/4b/74535248b1eac0c9336862e8617c765ac94dac76f9e25d7c4a79588c8907/psycopg2_binary-2.9.12-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40e7b28b63aaf737cb3a1edc3a9bbc9a9f4ad3dcb7152e8c1130e4050eddcb7d", size = 5894843, upload-time = "2026-04-20T23:34:40.856Z" }, + { url = "https://files.pythonhosted.org/packages/f2/ba/f1bf8d2ae71868ad800b661099086ee52bc0f8d9f05be1acd8ebb06757cc/psycopg2_binary-2.9.12-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:89d19a9f7899e8eb0656a2b3a08e0da04c720a06db6e0033eab5928aabe60fa9", size = 4110556, upload-time = "2026-04-20T23:34:44.016Z" }, + { url = "https://files.pythonhosted.org/packages/45/46/c15706c338403b7c420bcc0c2905aad116cc064545686d8bf85f1999ea00/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:612b965daee295ae2da8f8218ce1d274645dc76ef3f1abf6a0a94fd57eff876d", size = 3655714, upload-time = "2026-04-20T23:34:46.233Z" }, + { url = "https://files.pythonhosted.org/packages/b3/7c/a2d5dc09b64a4564db242a0fe418fde7d33f6f8259dd2c5b9d7def00fb5a/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b9a339b79d37c1b45f3235265f07cdeb0cb5ad7acd2ac7720a5920989c17c24e", size = 3301154, upload-time = "2026-04-20T23:34:49.528Z" }, + { url = "https://files.pythonhosted.org/packages/c0/e8/cc8c9a4ce71461f9ec548d38cadc41dc184b34c73e6455450775a9334ccd/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:3471336e1acfd9c7fe507b8bad5af9317b6a89294f9eb37bd9a030bb7bebcdc6", size = 3048882, upload-time = "2026-04-20T23:34:51.86Z" }, + { url = "https://files.pythonhosted.org/packages/19/6a/31e2296bc0787c5ab75d3d118e40b239db8151b5192b90b77c72bc9256e9/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7af18183109e23502c8b2ae7f6926c0882766f35b5175a4cd737ad825e4d7a1b", size = 3351298, upload-time = "2026-04-20T23:34:54.124Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a8/75f4e3e11203b590150abed2cf7794b9c9c9f7eceddae955191138b44dde/psycopg2_binary-2.9.12-cp312-cp312-win_amd64.whl", hash = "sha256:398fcd4db988c7d7d3713e2b8e18939776fd3fb447052daae4f24fa39daede4c", size = 2757230, upload-time = "2026-04-20T23:34:56.242Z" }, + { url = "https://files.pythonhosted.org/packages/91/bb/4608c96f970f6e0c56572e87027ef4404f709382a3503e9934526d7ba051/psycopg2_binary-2.9.12-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7c729a73c7b1b84de3582f73cdd27d905121dc2c531f3d9a3c32a3011033b965", size = 3712419, upload-time = "2026-04-20T23:34:58.754Z" }, + { url = "https://files.pythonhosted.org/packages/5e/af/48f76af9d50d61cf390f8cd657b503168b089e2e9298e48465d029fcc713/psycopg2_binary-2.9.12-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4413d0caef93c5cf50b96863df4c2efe8c269bf2267df353225595e7e15e8df7", size = 3822990, upload-time = "2026-04-20T23:35:00.821Z" }, + { url = "https://files.pythonhosted.org/packages/7a/df/aba0f99397cd811d32e06fc0cc781f1f3ce98bc0e729cb423925085d781a/psycopg2_binary-2.9.12-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4dfcf8e45ebb0c663be34a3442f65e17311f3367089cd4e5e3a3e8e62c978777", size = 4578696, upload-time = "2026-04-20T23:35:03.409Z" }, + { url = "https://files.pythonhosted.org/packages/95/9c/eaa74021ac4e4d5c2f83d82fc6615a63f4fe6c94dc4e94c3990427053f67/psycopg2_binary-2.9.12-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c41321a14dd74aceb6a9a643b9253a334521babfa763fa873e33d89cfa122fb5", size = 4274982, upload-time = "2026-04-20T23:35:05.583Z" }, + { url = "https://files.pythonhosted.org/packages/35/ed/c25deff98bd26187ba48b3b250a3ffc3037c46c5b89362534a15d200e0db/psycopg2_binary-2.9.12-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83946ba43979ebfdc99a3cd0ee775c89f221df026984ba19d46133d8d75d3cd9", size = 5894867, upload-time = "2026-04-20T23:35:07.902Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/8d0e21ca77373c6c9589e5c4528f6e8f0c08c62cafc76fb0bddb7a2cee22/psycopg2_binary-2.9.12-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:411e85815652d13560fbe731878daa5d92378c4995a22302071890ec3397d019", size = 4110578, upload-time = "2026-04-20T23:35:10.149Z" }, + { url = "https://files.pythonhosted.org/packages/00/fc/f481e2435bd8f742d0123309174aae4165160ad3ef17c1b99c3622c241d2/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c8ad4c08e00f7679559eaed7aff1edfffc60c086b976f93972f686384a95e2c", size = 3655816, upload-time = "2026-04-20T23:35:12.56Z" }, + { url = "https://files.pythonhosted.org/packages/53/79/b9f46466bdbe9f239c96cde8be33c1aace4842f06013b47b730dc9759187/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:00814e40fa23c2b37ef0a1e3c749d89982c73a9cb5046137f0752a22d432e82f", size = 3301307, upload-time = "2026-04-20T23:35:15.029Z" }, + { url = "https://files.pythonhosted.org/packages/3f/19/7dc003b32fe35024df89b658104f7c8538a8b2dcbde7a4e746ce929742e7/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:98062447aebc20ed20add1f547a364fd0ef8933640d5372ff1873f8deb9b61be", size = 3048968, upload-time = "2026-04-20T23:35:16.757Z" }, + { url = "https://files.pythonhosted.org/packages/91/58/2dbd7db5c604d45f4950d988506aae672a14126ec22998ced5021cbb76bb/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:66a7685d7e548f10fb4ce32fb01a7b7f4aa702134de92a292c7bd9e0d3dbd290", size = 3351369, upload-time = "2026-04-20T23:35:18.933Z" }, + { url = "https://files.pythonhosted.org/packages/42/ee/dee8dcaad07f735824de3d6563bc67119fa6c28257b17977a8d624f02fab/psycopg2_binary-2.9.12-cp313-cp313-win_amd64.whl", hash = "sha256:b6937f5fe4e180aeee87de907a2fa982ded6f7f15d7218f78a083e4e1d68f2a0", size = 2757347, upload-time = "2026-04-20T23:35:21.283Z" }, + { url = "https://files.pythonhosted.org/packages/13/1b/708c0dca874acfad6d65314271859899a79007686f3a1f74e82a2ed4b645/psycopg2_binary-2.9.12-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6f3b3de8a74ef8db215f22edffb19e32dc6fa41340456de7ec99efdc8a7b3ec2", size = 3712428, upload-time = "2026-04-20T23:35:23.453Z" }, + { url = "https://files.pythonhosted.org/packages/d6/39/ddbea9d4b4de6aca9431b6ed253f530f8a02d3b8f9bcfd0dbfe2b3de6fe4/psycopg2_binary-2.9.12-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1006fb62f0f0bc5ce256a832356c6262e91be43f5e4eb15b5eaf38079464caf2", size = 3823184, upload-time = "2026-04-20T23:35:25.92Z" }, + { url = "https://files.pythonhosted.org/packages/bf/a0/bc2fef74b106fa345567122a0659e6d94512ed7dc0131ec44c9e5aba3725/psycopg2_binary-2.9.12-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:840066105706cd2eb29b9a1c2329620056582a4bf3e8169dec5c447042d0869f", size = 4579157, upload-time = "2026-04-20T23:35:28.542Z" }, + { url = "https://files.pythonhosted.org/packages/57/d7/d4e3b2005d3de607ca4fbb0e8742e248056e52184a6b94ebda3c1c2c329b/psycopg2_binary-2.9.12-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:863f5d12241ebe1c76a72a04c2113b6dc905f90b9cef0e9be0efd994affd9354", size = 4274970, upload-time = "2026-04-20T23:35:30.418Z" }, + { url = "https://files.pythonhosted.org/packages/2e/42/c9853f8db3967fe08bcde11f53d53b85d351750cae726ce001cb68afa9c1/psycopg2_binary-2.9.12-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a99eaab34a9010f1a086b126de467466620a750634d114d20455f3a824aae033", size = 5895175, upload-time = "2026-04-20T23:35:33.584Z" }, + { url = "https://files.pythonhosted.org/packages/eb/fd/b82b5601a97630308bef079f545ffec481bbbc795c2ba5ec416a01d03f60/psycopg2_binary-2.9.12-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ffdd7dc5463ccd61845ac37b7012d0f35a1548df9febe14f8dd549be4a0bc81e", size = 4110658, upload-time = "2026-04-20T23:35:35.638Z" }, + { url = "https://files.pythonhosted.org/packages/62/8c/32ca69b0389ef25dd22937bf9e8fbe2ce27aea20b05ded48c4ce4cb42475/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:54a0dfecab1b48731f934e06139dfe11e24219fb6d0ceb32177cf0375f14c7b5", size = 3656251, upload-time = "2026-04-20T23:35:37.854Z" }, + { url = "https://files.pythonhosted.org/packages/c4/29/96992a2b59e3b9d730fcf9612d0a387305025dc867a9fc490a9e496e074e/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:96937c9c5d891f772430f418a7a8b4691a90c3e6b93cf72b5bd7cad8cbca32a5", size = 3301810, upload-time = "2026-04-20T23:35:39.927Z" }, + { url = "https://files.pythonhosted.org/packages/56/ad/44b06659949b243ae10112cd3b20a197f9bf3e81d5651379b9eb889bfaad/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:77b348775efd4cdab410ec6609d81ccecd1139c90265fa583a7255c8064bc03d", size = 3048977, upload-time = "2026-04-20T23:35:41.806Z" }, + { url = "https://files.pythonhosted.org/packages/1d/f2/10a1bcebadb6aa55e280e1f58975c36a7b560ea525184c7aa4064c466633/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:527e6342b3e44c2f0544f6b8e927d60de7f163f5723b8f1dfa7d2a84298738cd", size = 3351466, upload-time = "2026-04-20T23:35:43.993Z" }, + { url = "https://files.pythonhosted.org/packages/20/be/b732c8418ffa5bcfda002890f5dc4c869fc17db66ff11f53b17cfe44afc0/psycopg2_binary-2.9.12-cp314-cp314-win_amd64.whl", hash = "sha256:f12ae41fcafadb39b2785e64a40f9db05d6de2ac114077457e0e7c597f3af980", size = 2848762, upload-time = "2026-04-20T23:35:46.421Z" }, +] + +[[package]] +name = "pyasn1" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +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/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/60/1d1e59c9c90d54591469ada7d268251f71c24bdb765f1a8a832cee8c6653/pydantic_settings-2.14.1.tar.gz", hash = "sha256:e874d3bec7e787b0c9958277956ed9b4dd5de6a80e162188fdaff7c5e26fd5fa", size = 235551, upload-time = "2026-05-08T13:40:06.542Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/8d/f1af3832f5e6eb13ba94ee809e72b8ecb5eef226d27ee0bef7d963d943c7/pydantic_settings-2.14.1-py3-none-any.whl", hash = "sha256:6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de", size = 60964, upload-time = "2026-05-08T13:40:04.958Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +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/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "pytest-dotenv" +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "python-dotenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/b0/cafee9c627c1bae228eb07c9977f679b3a7cb111b488307ab9594ba9e4da/pytest-dotenv-0.5.2.tar.gz", hash = "sha256:2dc6c3ac6d8764c71c6d2804e902d0ff810fa19692e95fe138aefc9b1aa73732", size = 3782, upload-time = "2020-06-16T12:38:03.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/da/9da67c67b3d0963160e3d2cbc7c38b6fae342670cc8e6d5936644b2cf944/pytest_dotenv-0.5.2-py3-none-any.whl", hash = "sha256:40a2cece120a213898afaa5407673f6bd924b1fa7eafce6bda0e8abffe2f710f", size = 3993, upload-time = "2020-06-16T12:38:01.139Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "python-jose" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ecdsa" }, + { name = "pyasn1" }, + { name = "rsa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/77/3a1c9039db7124eb039772b935f2244fbb73fc8ee65b9acf2375da1c07bf/python_jose-3.5.0.tar.gz", hash = "sha256:fb4eaa44dbeb1c26dcc69e4bd7ec54a1cb8dd64d3b4d81ef08d90ff453f2b01b", size = 92726, upload-time = "2025-05-28T17:31:54.288Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/c3/0bd11992072e6a1c513b16500a5d07f91a24017c5909b02c72c62d7ad024/python_jose-3.5.0-py2.py3-none-any.whl", hash = "sha256:abd1202f23d34dfad2c3d28cb8617b90acf34132c7afd60abd0b0b7d3cb55771", size = 34624, upload-time = "2025-05-28T17:31:52.802Z" }, +] + +[package.optional-dependencies] +cryptography = [ + { name = "cryptography" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + +[[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/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { 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 = "rapidfuzz" +version = "3.14.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/21/ef6157213316e85790041254259907eb722e00b03480256c0545d98acd33/rapidfuzz-3.14.5.tar.gz", hash = "sha256:ba10ac57884ce82112f7ed910b67e7fb6072d8ef2c06e30dc63c0f604a112e0e", size = 57901753, upload-time = "2026-04-07T11:16:31.931Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/e3/574435c6aafb80254c191ef40d7aca2cb2bb97a095ec9395e9fa59ac307a/rapidfuzz-3.14.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0d3378f471ef440473a396ce2f8e97ee12f89a78b495540e0a5617bbfe895638", size = 1944601, upload-time = "2026-04-07T11:14:18.771Z" }, + { url = "https://files.pythonhosted.org/packages/d0/1f/fbad3102a255ecc112ce9a7e779bacab7fd14398217be8868dc9082ba363/rapidfuzz-3.14.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e910eebca9fd0eba245c0555e764597e8a0cccb673a92da2dc2397050725f48", size = 1164293, upload-time = "2026-04-07T11:14:20.534Z" }, + { url = "https://files.pythonhosted.org/packages/88/37/a3eb7ff6121ed3a5f199a8c38cc86c8e481816f879cb0e0b738b078c9a7e/rapidfuzz-3.14.5-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:01550fe5f60fd176aa66b7611289d46dc4aa4b1b904874c7b6d1d54e581c5ec1", size = 1371999, upload-time = "2026-04-07T11:14:22.63Z" }, + { url = "https://files.pythonhosted.org/packages/79/72/97a9728c711c7c1b06e107d3f0623880fb4ef90e147ed13c551a1730e7cc/rapidfuzz-3.14.5-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:48bee0b91bebfaec41e1081e351000659ab7570cc4598d617aa04d5bf827f9e6", size = 3145715, upload-time = "2026-04-07T11:14:24.508Z" }, + { url = "https://files.pythonhosted.org/packages/ed/54/d5caabbea233ac90c286c87c260e49d7641467e87438a18d858e41c82e91/rapidfuzz-3.14.5-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:7e580cb04ad849ae9b786fa21383c6b994b6e6c1444ad1cb9f22392759d72741", size = 1456304, upload-time = "2026-04-07T11:14:26.515Z" }, + { url = "https://files.pythonhosted.org/packages/fc/a7/2d1a81250ac8c01a0100c026018e76f0e7a097ff63e4c553e02a6938c6fb/rapidfuzz-3.14.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:09d6c9ba091854f07817055d795d604179c12a8f308ba4c7d56f3719dfea1646", size = 2389089, upload-time = "2026-04-07T11:14:28.635Z" }, + { url = "https://files.pythonhosted.org/packages/65/0d/c47c3872203ae88e6506997c0b576ad731f5261daa25d559be09c9756658/rapidfuzz-3.14.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1e989f86113be66574113b9c7bdf4793f3f863d248e47d911b355e05ca6b6b10", size = 2493404, upload-time = "2026-04-07T11:14:30.577Z" }, + { url = "https://files.pythonhosted.org/packages/8f/2f/71e0a5a3130792146c8a200a2dd1e52aa16f7c1074012e17f2601eea9a90/rapidfuzz-3.14.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0ebd1a18e2e47bc0b292a07e6ed9c3642f8aaa672d12253885f599b50807a4f9", size = 4251709, upload-time = "2026-04-07T11:14:32.451Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/d39874901abacef325adb5b34ae416817c8486dfb4fb87c7a9b74ec5b072/rapidfuzz-3.14.5-cp312-cp312-win32.whl", hash = "sha256:9981d38a703b86f0e315a3cd229fd1906fe1d91c989ed121fb975b3c849f89f5", size = 1710069, upload-time = "2026-04-07T11:14:34.37Z" }, + { url = "https://files.pythonhosted.org/packages/85/0b/f65572c53de8a1c704bda707f63a447b67bdbe95d7cdc70d18885e191df5/rapidfuzz-3.14.5-cp312-cp312-win_amd64.whl", hash = "sha256:d8375e3da319593389727c3187ccaf3e0e84199accc530866b8e0f2b79af05e9", size = 1540630, upload-time = "2026-04-07T11:14:36.287Z" }, + { url = "https://files.pythonhosted.org/packages/5e/c3/143be3a578f989758cae516f3270d5cbb49783a7bfdf57cc27a670e00456/rapidfuzz-3.14.5-cp312-cp312-win_arm64.whl", hash = "sha256:478b59bb018a6780d73f33e38d0b3ec5e968a6c1ed42876b993dd456b7aa20e8", size = 813137, upload-time = "2026-04-07T11:14:38.289Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/252803f2010ba699618cdc048b6e1f7cc1f433c08b4a9a17579b92ab0142/rapidfuzz-3.14.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ebd8fd343bf8492a1e60bcb6dc99f90f74f65d98d8241a6b3e1fed225b76ecd6", size = 1940205, upload-time = "2026-04-07T11:14:40.319Z" }, + { url = "https://files.pythonhosted.org/packages/ea/59/b2afd98e41af9cd54554a4c1c423d84cdd60e6b1c0a09496f033b55f60ec/rapidfuzz-3.14.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6737b35d5af7479c5bf9710f7b17edd9d2c43128d974d25fb4ea653e42c64609", size = 1159639, upload-time = "2026-04-07T11:14:42.52Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/7aa7e62c4c516a7af322ed0c4f0774208b72d457d0cfec808bad0df12f4a/rapidfuzz-3.14.5-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b002c7994cc9f2bc9d9856f0fbaee6e8072c983873846c92f25cefba5b2a925f", size = 1367194, upload-time = "2026-04-07T11:14:44.25Z" }, + { url = "https://files.pythonhosted.org/packages/90/79/2fc252a63bc91d3c3b234d0a3a6ad4ebc460037a23cdcdaf9285f986e6c9/rapidfuzz-3.14.5-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:17a34330cd2a538c1ce5d400b61ba358c5b72c654b928ff87b362e88f8b864c7", size = 3151805, upload-time = "2026-04-07T11:14:46.21Z" }, + { url = "https://files.pythonhosted.org/packages/17/54/0c83508f2683ea70e2d05f8527eb07328acf7bb1e9d97a3bece5702378e7/rapidfuzz-3.14.5-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:95d937e74c1a7a1287dfb03b62a827be08ede10a155cf1af73bbf47f2b73ee6e", size = 1455667, upload-time = "2026-04-07T11:14:47.991Z" }, + { url = "https://files.pythonhosted.org/packages/71/1b/070175e873177814d58850a01ebe80e20ae11e93eb4da894d563988660fa/rapidfuzz-3.14.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:46b92a9970dcc34f0096901c792644094cab49554ac3547f35e3aebbdf0a3610", size = 2388246, upload-time = "2026-04-07T11:14:50.098Z" }, + { url = "https://files.pythonhosted.org/packages/c9/dd/77caf7aaf9c2be050ad1f128d7c24ff0f59079aa62c5f62f9df41c0af45e/rapidfuzz-3.14.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e012177c8e8a8a0754ae0d6027d63042aa5ff036d9f40f07cb3466a6082e21b8", size = 2494333, upload-time = "2026-04-07T11:14:52.303Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/dd7e1f2aa31a8fbbfc16b0610af1d770ffaf1287490f3c8c5b1c52da264f/rapidfuzz-3.14.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a2ae6f53f99c9a0eca7a0afc5b4e45fc73bc1dd4ac74c00509031d76df80ed98", size = 4258579, upload-time = "2026-04-07T11:14:54.538Z" }, + { url = "https://files.pythonhosted.org/packages/9c/0a/ac99e1ba347ba0e85e0bb60b74231d55fb93c0eff43f2920ccb413d0be08/rapidfuzz-3.14.5-cp313-cp313-win32.whl", hash = "sha256:4a60f0057231188e3bd30216f7b4e0f279b11fa4ec818bb6c1d9f014d1562fbc", size = 1709231, upload-time = "2026-04-07T11:14:56.524Z" }, + { url = "https://files.pythonhosted.org/packages/cf/cb/0e251d731b3166378644238e8f0cf9e89858c024e19f75ca9f7e3ae83fd5/rapidfuzz-3.14.5-cp313-cp313-win_amd64.whl", hash = "sha256:11bfc2ed8fbe4ab86bd516fadefab126f90e6dcadffa761739fcb304707dfd35", size = 1538519, upload-time = "2026-04-07T11:14:58.635Z" }, + { url = "https://files.pythonhosted.org/packages/30/6f/4548132acc947db6d5346a248e44a8b3a22d608ef30e770fb578caaf2d00/rapidfuzz-3.14.5-cp313-cp313-win_arm64.whl", hash = "sha256:b486b5218808f6f4dc471b114b1054e63553db69705c97da0271f47bd706aedd", size = 812628, upload-time = "2026-04-07T11:15:00.552Z" }, + { url = "https://files.pythonhosted.org/packages/00/60/69b177577290c5eab892c6f75fe89c3aff3f9ae80298a78d9372b1cecb9a/rapidfuzz-3.14.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:39ef8658aaf67d51667e7bdaf7096f432333377d8302ac43c70b5df8a4cf89b8", size = 1970231, upload-time = "2026-04-07T11:15:02.603Z" }, + { url = "https://files.pythonhosted.org/packages/48/38/2fd790052659cc4e2907b63c25433f0987864b445c1aeec1a302ef5ad948/rapidfuzz-3.14.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9ad37a0be705b544af6296da8edddc260d10a8ae5462530fc9991f66498bb1f9", size = 1194394, upload-time = "2026-04-07T11:15:04.572Z" }, + { url = "https://files.pythonhosted.org/packages/80/f4/28430ad8472fc3536e8ebd51a864a226e979cfe924c6e3f83d111373aa74/rapidfuzz-3.14.5-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d45e06f60729e07d9b20c205f7e5cff90b6ef2584e852eecf46e045aea69627d", size = 1377051, upload-time = "2026-04-07T11:15:06.728Z" }, + { url = "https://files.pythonhosted.org/packages/77/7e/9aeacabcfd1e77397968362e5b98fe14248b8307011136b17daf99752a8e/rapidfuzz-3.14.5-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e52da10236aa6212de71b9e170bace65b64b129c0dea7fc243d6c9ce976f5074", size = 3160565, upload-time = "2026-04-07T11:15:08.667Z" }, + { url = "https://files.pythonhosted.org/packages/56/f4/db4dd7be0cd2f2022117ac5407d905f435d60e48baaea313a567ad27e865/rapidfuzz-3.14.5-cp313-cp313t-manylinux_2_39_riscv64.whl", hash = "sha256:440d30faaf682ca496170a7f0cc5453ec942e3e079f0fd802c9a7f938dfb50a3", size = 1442113, upload-time = "2026-04-07T11:15:11.138Z" }, + { url = "https://files.pythonhosted.org/packages/a4/99/0e9f6aa57f3e32a767216f797e56dc96b720fcecfb9d8ee907ecc82f8d66/rapidfuzz-3.14.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:56227a61fd3d17b0cd9793132431f3a3d07c8654be96794ba9f89fe0fc8b2d09", size = 2396618, upload-time = "2026-04-07T11:15:13.154Z" }, + { url = "https://files.pythonhosted.org/packages/60/94/44a78e39ffce17cbdd3e2b53b696acc751d5d153be0f499d052b07a4d904/rapidfuzz-3.14.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:2e83cd2e25bb4edd97b689d9979d9c3acccdaaf26ceac08212ceece202febcfa", size = 2478220, upload-time = "2026-04-07T11:15:15.193Z" }, + { url = "https://files.pythonhosted.org/packages/dd/df/454311469a09a507e9d784a35796742bec22e4cebe75551e2da4e0e290fd/rapidfuzz-3.14.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:af3b859726cd3374287e405e14b9634563c078c5531a4f62375508addebddad1", size = 4265027, upload-time = "2026-04-07T11:15:17.28Z" }, + { url = "https://files.pythonhosted.org/packages/fc/01/175465a9ab3e3b70ba669058372f009d1d49c1746e2dcd56b69df188d3a5/rapidfuzz-3.14.5-cp313-cp313t-win32.whl", hash = "sha256:8ce1d850b3c0178440efde9e884d98421b5e87ff925f364d6d79e23910d7593f", size = 1766814, upload-time = "2026-04-07T11:15:19.687Z" }, + { url = "https://files.pythonhosted.org/packages/1b/a0/a9b84a47af06ebed94a1439eb2f02adebfb8628bcd30af1fe3e02f5ef56c/rapidfuzz-3.14.5-cp313-cp313t-win_amd64.whl", hash = "sha256:c84af70bcf34e99aee894e46a0f1ac77f17d0ef828179c387407642e2466d28a", size = 1582448, upload-time = "2026-04-07T11:15:21.98Z" }, + { url = "https://files.pythonhosted.org/packages/1e/f1/5937800238b3f8248e70860d79f69ba8f73e764fff47e36bc9e2f26dbcc6/rapidfuzz-3.14.5-cp313-cp313t-win_arm64.whl", hash = "sha256:aac0ad28c686a5e72b81668b906c030ee28050b244544b8af68e12fb32543895", size = 832932, upload-time = "2026-04-07T11:15:24.358Z" }, + { url = "https://files.pythonhosted.org/packages/81/41/aa3ffb3355e62e1bf91f6599b3092e866bc88487a07c524004943c7676df/rapidfuzz-3.14.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1a31cc6d7d03e7318a0974c038959c59e19c752b81115f2e9138b3331cd64d45", size = 1943327, upload-time = "2026-04-07T11:15:26.266Z" }, + { url = "https://files.pythonhosted.org/packages/2d/e1/c2141f1840a41e07ad2db6f724945f8f8ff3065463899a22939152dd6e09/rapidfuzz-3.14.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0298d357e2bc59d572da4db0bc631009b6f8f6c9bc8c11e99a12b833f16b6575", size = 1161755, upload-time = "2026-04-07T11:15:28.659Z" }, + { url = "https://files.pythonhosted.org/packages/ca/07/66e753eeaa353161d1d331b7dd517bb349b0bacfebe8496d7b26be26f81f/rapidfuzz-3.14.5-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:59b3dba758661a318995655435c6ab20a04ade79fa51e75bc8dc107cac8df280", size = 1376571, upload-time = "2026-04-07T11:15:31.225Z" }, + { url = "https://files.pythonhosted.org/packages/c8/85/9535df0b78ba51f478c9ce7eb6d1f85535cc31fe356773b48fd9d3e563ca/rapidfuzz-3.14.5-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4900143d82071bdda533b00300c40b14b963ff826b3642cc463b6dd0f036585e", size = 3156468, upload-time = "2026-04-07T11:15:33.428Z" }, + { url = "https://files.pythonhosted.org/packages/81/ee/b667eb93bba6dc4e0de658edd778e1619dc4d6aab68fa5e5c7f075152735/rapidfuzz-3.14.5-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:feedf219672eef83ea6be6f3bb093bba396a8560fc75be85ba225f082903df0a", size = 1458311, upload-time = "2026-04-07T11:15:35.557Z" }, + { url = "https://files.pythonhosted.org/packages/7d/ce/479074f5624364a48df3403c538797ef22d3ac49c19dc76c3f79fcdcc70c/rapidfuzz-3.14.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:419e4397a36e2665ec992d8d64c20ba4b2a42500c76ecadeca78a4f19cb9cc32", size = 2398228, upload-time = "2026-04-07T11:15:37.669Z" }, + { url = "https://files.pythonhosted.org/packages/0b/15/a8982f649150fffbdcd6f17565974501f6ab33b2795267bffbd4a7ba905b/rapidfuzz-3.14.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:97131ab2be39043054ee28d99e09efe316e6d53449b7e962dfcf3c2de8b2b246", size = 2497226, upload-time = "2026-04-07T11:15:39.857Z" }, + { url = "https://files.pythonhosted.org/packages/19/52/5267c03ef6759831b7d4625a0c9c06e87baa2fae084b61ac9c388858317b/rapidfuzz-3.14.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:593c00dac4e30231c35bf3b4f1da8ec0998762e9e94425586a5d636fcd57f9d0", size = 4262283, upload-time = "2026-04-07T11:15:42.279Z" }, + { url = "https://files.pythonhosted.org/packages/71/c0/2579f343a97f5254c43bb5853baccc01488357dcb64a27bcb869b7888a4a/rapidfuzz-3.14.5-cp314-cp314-win32.whl", hash = "sha256:0084b687b02b4e569b46d8d6d4ad25659528e6081cd6d067ca453a69035f07e4", size = 1744614, upload-time = "2026-04-07T11:15:44.498Z" }, + { url = "https://files.pythonhosted.org/packages/17/eb/8edfed1e80119dc9c35b11df4bc701eea85622ad681fff0263b6961d3224/rapidfuzz-3.14.5-cp314-cp314-win_amd64.whl", hash = "sha256:5dfa89d78f22cd773054caff44827b846161a29f2dcf7e78b8f90d086621e502", size = 1588971, upload-time = "2026-04-07T11:15:46.86Z" }, + { url = "https://files.pythonhosted.org/packages/f6/04/5676df93c85cfa57a3045d8047318df9f3cd58c7b8a99340dd95f874795e/rapidfuzz-3.14.5-cp314-cp314-win_arm64.whl", hash = "sha256:67f3f9d2b444268ab53e47d31bab89954888d23c04c6789f2c727e51fe4b1d13", size = 834985, upload-time = "2026-04-07T11:15:49.411Z" }, + { url = "https://files.pythonhosted.org/packages/f7/0d/4a8988cea658fe335048ddef8c876addff1b6daa3c9ca8ad65a5a2196e69/rapidfuzz-3.14.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:77eac0526899b3c3ad1454bb2b03cdb491d67358ec8ef0c9c48bd61b632b431d", size = 1972517, upload-time = "2026-04-07T11:15:51.819Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a3/f5cfd9965a9d9a9e32249159797c47b5d6299ea6d1629f9126b25f1c10a3/rapidfuzz-3.14.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b9c6bd754d11f6e78ac54e3d86b4b11dc1ba2f13e5fc958899574532897f5a99", size = 1196056, upload-time = "2026-04-07T11:15:54.292Z" }, + { url = "https://files.pythonhosted.org/packages/64/07/561c2e40cfd10e6630a7b0ac5a2a813aef50d944bcd1f3d260319d659d5b/rapidfuzz-3.14.5-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:738c96944d076deeaff70e92b65696ab4f7ecb8081d7791c5403a3257dfaf8ff", size = 1374732, upload-time = "2026-04-07T11:15:56.584Z" }, + { url = "https://files.pythonhosted.org/packages/c2/39/123bb94fee40e2fb3b7c49b80827c7ef42d838e18def3fc2fef5a3cf817a/rapidfuzz-3.14.5-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4c1bca487a17fe4226b4ffb2d30e799d2b274d692cffa76bd0746f56235fca3", size = 3166902, upload-time = "2026-04-07T11:15:58.768Z" }, + { url = "https://files.pythonhosted.org/packages/75/0a/45716fafc9fd2e028cf20b5ac5bc704887081cd312f84edb0e325599414b/rapidfuzz-3.14.5-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:af6a90a4ed2a48fa1a2d17e9d824e6c7c950bea5bad0b707c77fd55751e6bfef", size = 1452130, upload-time = "2026-04-07T11:16:01.453Z" }, + { url = "https://files.pythonhosted.org/packages/ca/49/4e96c413114398481c0a5b0086af32c364a18613c9a2ea578d17c4bea4ee/rapidfuzz-3.14.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bf5018938208d4597b2e679a4f8cff9fd252f1df53583130ae56281a21801b64", size = 2396308, upload-time = "2026-04-07T11:16:03.588Z" }, + { url = "https://files.pythonhosted.org/packages/89/b7/49fea9fc6878d59bd259d01dd1972d9b86117992b1c66d9b16f0a65273c3/rapidfuzz-3.14.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c0919d1f89ddf91129906705723118ea09754171e4116f5a5dbc667c7bc9b261", size = 2488210, upload-time = "2026-04-07T11:16:05.871Z" }, + { url = "https://files.pythonhosted.org/packages/0c/44/a1f732b93ffacbdad077b7c801149549b2938e1bece6addb5ad85ed74df8/rapidfuzz-3.14.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:93d8da883a35116d6813432177f35e570db5b0a5e30ecb0cbd7cb39c815735df", size = 4270621, upload-time = "2026-04-07T11:16:08.483Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ce/ff942d19fce5385054650bb71a58495ddda299d94661ccc4e6e7fa44868b/rapidfuzz-3.14.5-cp314-cp314t-win32.whl", hash = "sha256:0f23e37019ec07712d58976b1ab2b889f8649a7f7c2f626a2f34ea9139e79279", size = 1803950, upload-time = "2026-04-07T11:16:10.873Z" }, + { url = "https://files.pythonhosted.org/packages/5c/0f/9aafc63f9661222b819b391c187eed29fc90ad5935f9690e5ecc2d2047a4/rapidfuzz-3.14.5-cp314-cp314t-win_amd64.whl", hash = "sha256:7d5ca9c7832e6879a707296d1463685f7c243a27846227044504741640caec66", size = 1632357, upload-time = "2026-04-07T11:16:13.1Z" }, + { url = "https://files.pythonhosted.org/packages/70/a6/51fc1b0e61e3326e1c68a61cfd0c6b3c34c843681c4b1eefbf0596f59162/rapidfuzz-3.14.5-cp314-cp314t-win_arm64.whl", hash = "sha256:3e91dcd2549b8f8d843f98ba03a17e01f3d8b72ce942adbbb6761bc58ffce813", size = 855409, upload-time = "2026-04-07T11:16:15.787Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "resend" +version = "2.32.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dc/d5/2c46334104cce931170d756c7478263744b3eb168179c329d90a065f9b6b/resend-2.32.2.tar.gz", hash = "sha256:448001810f32e7aea39bab27318263fdc69f5764be16b7c6241ed7714dc07ea3", size = 46202, upload-time = "2026-06-17T19:47:28.243Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/e9/64b7f15e4d2b9bc363e1a6e0abc518f7aea4b8dfeab8d15a25e508c31fb2/resend-2.32.2-py2.py3-none-any.whl", hash = "sha256:1a8df5ef54b3a5cb7bb18b745f85fda07f1fa8f79b83dd9a25f6e37fbd625ef2", size = 72664, upload-time = "2026-06-17T19:47:26.915Z" }, +] + +[[package]] +name = "rsa" +version = "4.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/dc/35b341fc554ba02f217fc10da57d1a75168cfbcf75b0ef2202176d4c4f2d/ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566", size = 4755489, upload-time = "2026-06-25T17:20:37.578Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/d9/2d5014f0253ba541d2061d9fa7193f48e941c8b21bb88a7ff9bbe0bd0596/ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078", size = 10839665, upload-time = "2026-06-25T17:19:44.702Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d3/ac1798ba64f670698867fcfc591d50e7e421bef137db564858f619a30fcf/ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b", size = 11208649, upload-time = "2026-06-25T17:19:48.787Z" }, + { url = "https://files.pythonhosted.org/packages/47/47/d3ac899991202095dfcf3d5176be4272642be3cf981a2f1a30f72a2afb95/ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632", size = 10622638, upload-time = "2026-06-25T17:19:51.354Z" }, + { url = "https://files.pythonhosted.org/packages/33/13/4e043fe30aa94d4ff5213a9881fc296d12960f5971b234a5263fdc225312/ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd", size = 10984227, upload-time = "2026-06-25T17:19:54.044Z" }, + { url = "https://files.pythonhosted.org/packages/76/e6/92e7bf40388bc5800073b96564f56264f7e48bfd1a498f5ced6ae6d5a769/ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b", size = 10622882, upload-time = "2026-06-25T17:19:57.037Z" }, + { url = "https://files.pythonhosted.org/packages/13/7a/43460be3f24495a3aa46d4b16873e2c4941b3b5f0b00cf88c03b7b94b339/ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267", size = 11474808, upload-time = "2026-06-25T17:20:00.357Z" }, + { url = "https://files.pythonhosted.org/packages/27/a0/f37077884873221c6b33b4ab49eb18f9f88e54a16a25a5bca59bef46dd66/ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c", size = 12293094, upload-time = "2026-06-25T17:20:03.446Z" }, + { url = "https://files.pythonhosted.org/packages/a6/74/165545b60256a9704c21ac0ec4a0d07933b320812f9584836c9f4aca4292/ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae", size = 11526176, upload-time = "2026-06-25T17:20:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/86/b1/a976a136d40ade83ce743578399865f57001003a409acadc0ecbb3051082/ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b", size = 11520767, upload-time = "2026-06-25T17:20:09.191Z" }, + { url = "https://files.pythonhosted.org/packages/19/0f/f032696cb01c9b54c0263fa393474d7758f1cdc021a01b04e3cbc2500999/ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487", size = 11500132, upload-time = "2026-06-25T17:20:13.602Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f4/51b1a14bc69e8c224b15dab9cce8e99b425e0455d462caa2b3c9be2b6a8e/ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3", size = 10943828, upload-time = "2026-06-25T17:20:16.635Z" }, + { url = "https://files.pythonhosted.org/packages/71/4b/fe267640783cd02bf6c5cc290b1df1051be2ec294c678b5c15fe19e52343/ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053", size = 10645418, upload-time = "2026-06-25T17:20:19.4Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c0/a65aa4ec2f5e87a1df32dc3ec1fede434fe3dfd5cbcf3b503cafc676ab54/ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4", size = 11211770, upload-time = "2026-06-25T17:20:22.033Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/0caa331d954ae2723d729d351c989cb4ca8b6077d5c6c2cb6de75e98c041/ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460", size = 11618698, upload-time = "2026-06-25T17:20:25.259Z" }, + { url = "https://files.pythonhosted.org/packages/10/9b/5f14927848d2fd4aa891fd88d883788c5a7baba561c7874732364045708c/ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21", size = 10857322, upload-time = "2026-06-25T17:20:28.612Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f0/fe47c501f9dea92a26d788ff98bb5d92ed4cb4c88792c5c88af6b697dc8e/ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415", size = 11993274, upload-time = "2026-06-25T17:20:31.871Z" }, + { url = "https://files.pythonhosted.org/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca", size = 11343498, upload-time = "2026-06-25T17:20:35.03Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.50" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/da/6fbf010c8ebb347679d0d100b22fe9ba5e13fd04046c5df7280d2f0bf706/sqlalchemy-2.0.50.tar.gz", hash = "sha256:af5607d11ef90fd6a5c0549fe0045dce1663d427426bcfb506dcb5346a85a3b9", size = 9907424, upload-time = "2026-05-24T19:20:04.018Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/b0/a9d19b43f38f878b1278bca5b00b909f7540d41494396dd2561f9ad0956d/sqlalchemy-2.0.50-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23ae23d8b9d344d30d0a92f06d45825024a5790f1c1dd4cf452636a50d3e58cb", size = 2159807, upload-time = "2026-05-24T19:27:53.086Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2c/191dd58a248fd2cfd4780fa82c375c505e4ad98c8b522fa69ec492130d77/sqlalchemy-2.0.50-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:47b71b933e7b4ebad407c8fdfd70d2c4f08b78b3238bb30eebdd6eb32ca51b89", size = 3343358, upload-time = "2026-05-24T20:09:29.279Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2b/514fce8a7df81cf5bad7ff7865de7ac0c5776a38cc043475c4703eb7fe8b/sqlalchemy-2.0.50-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:110fdac56ace278949f00de805edacbd6141e382d992f9ba28238b3a0827a600", size = 3357994, upload-time = "2026-05-24T20:17:13.495Z" }, + { url = "https://files.pythonhosted.org/packages/35/a6/a0e283f5494f92b0d77e319ff77e437b1ffe4a051ba67c81d53234825475/sqlalchemy-2.0.50-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0f5e4ac70e9e757f6b3e87c0491ff034442ecd8dfd36d041a50564c322dafc0e", size = 3289399, upload-time = "2026-05-24T20:09:32.239Z" }, + { url = "https://files.pythonhosted.org/packages/b7/96/1b07325ba71752d6a028b77d07bed1483ad545f794e8b1dc89b3ba3b3c68/sqlalchemy-2.0.50-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:724f3dcbe53dd0151e3cb5e7ec4ba4c620bede579caacd16275dc35ce06e8615", size = 3321216, upload-time = "2026-05-24T20:17:15.581Z" }, + { url = "https://files.pythonhosted.org/packages/ed/8e/bad6ed253e8a99edfc99af02f7173ec48a1d3ed1b9b35a1b8bc1700900cc/sqlalchemy-2.0.50-cp312-cp312-win32.whl", hash = "sha256:1208050441471d003b7c8cb4054fb084f185cf35ac3f0ea270803865bca9939a", size = 2119194, upload-time = "2026-05-24T19:50:04.943Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2d/314a6690dda4b9cfc571eab1a63cf6fe6e1470aa3759ccda6aa016ee0f5a/sqlalchemy-2.0.50-cp312-cp312-win_amd64.whl", hash = "sha256:9d1af51558029a156a70986b7df88f042b3d158d7c8d8fb5072912d4b32d89c7", size = 2146186, upload-time = "2026-05-24T19:50:06.74Z" }, + { url = "https://files.pythonhosted.org/packages/0b/c4/c42356b527296e9862f67990efce31ef78b4cf69cd3f80873a528a060320/sqlalchemy-2.0.50-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:06a9210bdc5f4298cff0781087e2ff45683922252dacc452846373a58761f093", size = 2156697, upload-time = "2026-05-24T19:27:54.764Z" }, + { url = "https://files.pythonhosted.org/packages/60/a1/b1a70e3c4365ac7fe9e347f3710f19b562c866fb96d45e3c891588789a7b/sqlalchemy-2.0.50-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b53784972ade4f8174b9aa661f31a06f8a936d2cfdd602913ff3c6dd40ae873", size = 3284260, upload-time = "2026-05-24T20:09:34.195Z" }, + { url = "https://files.pythonhosted.org/packages/3f/4a/f3ac3caa19f263d57b0a47f8c91bbf56583dc2d3fc63acfbf644abb24fe0/sqlalchemy-2.0.50-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:31648fa14460537e768a7303b078e4344d208e0d23e06867c1f376a227ed82db", size = 3302280, upload-time = "2026-05-24T20:17:17.825Z" }, + { url = "https://files.pythonhosted.org/packages/66/55/ccada3e3d62254587819749a0bc69f41173eb48a6e385d10e66d32a9c88e/sqlalchemy-2.0.50-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:03f4323c980ad0e918cc9e5369b015f759f4e534db5bbaf4dc36832c10d05064", size = 3231580, upload-time = "2026-05-24T20:09:36.406Z" }, + { url = "https://files.pythonhosted.org/packages/05/f6/6809349130a2de0e109e7f00fd7d431da9565b9b2868b32ee684754f672b/sqlalchemy-2.0.50-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2b9dcc43afef8ac157cd92fce96985d6b8b0cfbd3df4d666f66b4d55a75d202f", size = 3269375, upload-time = "2026-05-24T20:17:20.34Z" }, + { url = "https://files.pythonhosted.org/packages/48/84/278a811ef4e07be9c89dc5cdd7be833268509a66a68c4897cf585e67428f/sqlalchemy-2.0.50-cp313-cp313-win32.whl", hash = "sha256:60922d6599065ddca2c6f376b9aa2f41a6b85a271725e0909490bbc50b1998a5", size = 2117229, upload-time = "2026-05-24T19:50:08.215Z" }, + { url = "https://files.pythonhosted.org/packages/f6/1c/067cc6187ed32d2ec222fe6d2643acc1659a6d0659f8a7cbc5ad3ae83280/sqlalchemy-2.0.50-cp313-cp313-win_amd64.whl", hash = "sha256:287086e67275a212c4582d166a6fb03a65ccc5551d80866270ce0dd9f34eccd3", size = 2143126, upload-time = "2026-05-24T19:50:09.691Z" }, + { url = "https://files.pythonhosted.org/packages/df/32/10ac51b4be7cdecd7e93d069251c86dfbf70b7adbd7c67b48ccea6c49e1c/sqlalchemy-2.0.50-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c966932507a4d7d0a37314927dbfcd89720e3f37d2a1e3352e7ae7939fa8e8a0", size = 2158519, upload-time = "2026-05-24T19:27:56.472Z" }, + { url = "https://files.pythonhosted.org/packages/5a/76/e703d2f7681d7d66c4c891af3f07c7ccf4c76ad7f18351de035b5eda007a/sqlalchemy-2.0.50-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:faffef4bcc20a1892e65e155293d99d60855bbbc79250ab712819cfd56a8e6bb", size = 3282063, upload-time = "2026-05-24T20:09:38.57Z" }, + { url = "https://files.pythonhosted.org/packages/31/26/ef168b184a25701f9995e8fb7e503fafd7a99c1c77cda1bc1a26ea2ed486/sqlalchemy-2.0.50-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c206aec519a2e7bd08abbfb33436e325fd22c632d9c21a9047e376ce241646e", size = 3287069, upload-time = "2026-05-24T20:17:21.942Z" }, + { url = "https://files.pythonhosted.org/packages/c2/15/765acc2bc693bccc43ca4a95d5b69750da8aaf6db1b5c616536e087f8920/sqlalchemy-2.0.50-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bef4ac756363227ef6402a75fee025a4bc690f92328e825868939b3b3a446a6d", size = 3230453, upload-time = "2026-05-24T20:09:40.398Z" }, + { url = "https://files.pythonhosted.org/packages/63/61/08e03c3adbf5db0087a0b6816746fec8f3032fb2f7fc899a9bb9b2a48ce4/sqlalchemy-2.0.50-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:96fbee6b19c19cd1556c8bf9419447cf2ec149ffcab7ab64348c23e54ef8547f", size = 3252413, upload-time = "2026-05-24T20:17:24.067Z" }, + { url = "https://files.pythonhosted.org/packages/03/0c/370a1f2db38436c615e10134c8a37de3688e74084792380695f3f5083860/sqlalchemy-2.0.50-cp314-cp314-win32.whl", hash = "sha256:8f00e3eb43ba30eb1b238ee03a8a62309486d1321eda3328bb611e0340033ad8", size = 2120063, upload-time = "2026-05-24T19:50:11.08Z" }, + { url = "https://files.pythonhosted.org/packages/7f/a0/fe92bb9817863bc13ba093bda931979a26cc2ca69f8e8f26d07add3d7c6f/sqlalchemy-2.0.50-cp314-cp314-win_amd64.whl", hash = "sha256:15708c613cd5005b7dffe1f66ee6a63ee8f5e46799f71c70ebad74178c676a39", size = 2145830, upload-time = "2026-05-24T19:50:12.452Z" }, + { url = "https://files.pythonhosted.org/packages/cc/ff/e5640a98a0b2f491eb8fde10fb6c773621a2e44340de231fafcc9370f4a9/sqlalchemy-2.0.50-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3699dac4be410e97049a1658e9480da9cde956594aa0f3aebc60b88f21c5ba70", size = 2178435, upload-time = "2026-05-24T19:42:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/b7/85/337116e186f1236375b5fb70c21cfac98e8e8ab0d3a47be838dc47a59e08/sqlalchemy-2.0.50-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f96233858e3df43932ac11589e22520da6e8aeb624b03fedfeebb0e8ea213086", size = 3566059, upload-time = "2026-05-24T20:01:20.848Z" }, + { url = "https://files.pythonhosted.org/packages/96/34/bb0e190e161c3c2c24314a65add57218be14a4a9486886b7f5047c1ff7c8/sqlalchemy-2.0.50-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c4e70c46fad30c3bcc6a4708bc0130a3173e11a5b25f0ea4a9d8911b450f1f52", size = 3535366, upload-time = "2026-05-24T20:03:56.768Z" }, + { url = "https://files.pythonhosted.org/packages/df/5a/a7f759f97e4fd499c5d4e4488c760d5a7fbecf3028b465a04274fcd52384/sqlalchemy-2.0.50-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1918a3cf564d16d95bca7301005f41ab2ad50b07cd3b9da50d3ed986db148d6a", size = 3474879, upload-time = "2026-05-24T20:01:23.058Z" }, + { url = "https://files.pythonhosted.org/packages/9d/d9/2907ea38eb60687d297bf9c39e5ee58053c87b57fe8a9cae97090cecbf10/sqlalchemy-2.0.50-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b00098cdbdbd38c7be3d568b0c9c3122b8c0ec62b911b57cd5e6e0254d60a76d", size = 3486117, upload-time = "2026-05-24T20:03:59.052Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e3/5aa06f167559f8c0bdae487e297d23ba548150ab016a3418265d617a4985/sqlalchemy-2.0.50-cp314-cp314t-win32.whl", hash = "sha256:1fbd55a969d7ac44a98e3dec75016074f809fa08f871585ace58dde110d1bf3e", size = 2150823, upload-time = "2026-05-24T20:08:58.644Z" }, + { url = "https://files.pythonhosted.org/packages/65/9b/112fb8f977582d7489d036e409e3723948bcf5320b3ac465f3c481bbe8f9/sqlalchemy-2.0.50-cp314-cp314t-win_amd64.whl", hash = "sha256:c5c3cdb753a9004183e1ccb634b41611654c989e61bc68617ce878e46d6f1e51", size = 2185794, upload-time = "2026-05-24T20:09:00.319Z" }, + { url = "https://files.pythonhosted.org/packages/d0/10/f7220e9b784d295d241c86ed99aeb537f92afcd469a64861f2717e9bb077/sqlalchemy-2.0.50-py3-none-any.whl", hash = "sha256:92064363517a3ff8212b5a93b8c62876579d8dfd1ca5b561335f30152d884fa9", size = 1943861, upload-time = "2026-05-24T19:59:01.119Z" }, +] + +[[package]] +name = "starlette" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/25/44/ec35f1b6e83094b997da438a02c8c9b0ade2b1e84cfc48bd4656780760a6/starlette-1.2.1.tar.gz", hash = "sha256:9b9b5ebb992e67d6093741e63c2f59e4f6fff986f81163c087867bd7b924b3f6", size = 2701854, upload-time = "2026-05-31T01:07:51.847Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/54/196d0c1db10af76baa4f64894448505d60d3cdf70ef92cbb35f46a4e4c71/starlette-1.2.1-py3-none-any.whl", hash = "sha256:4de0082d08c8f6764a85a54cf1120d6939507a19905c7768acad2a9f875d2b89", size = 73350, upload-time = "2026-05-31T01:07:50.09Z" }, +] + +[[package]] +name = "stripe" +version = "15.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/28/c3550f39d9e258c21bcba81a89ef3ca7af352ad2cb14fe16b80248841644/stripe-15.2.0.tar.gz", hash = "sha256:f795d8a8ff27433e9ab03099afc0f5c4e992a8a6f92b9f839dc0048bb12f76f6", size = 1520207, upload-time = "2026-05-27T20:34:44.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/2b/697003ba6349058b65a5cb6b0bd8a8b0e909ed35911f7beebfc24b0563ee/stripe-15.2.0-py3-none-any.whl", hash = "sha256:e74d45bb07bb912fdc4ce81ffbdf184bcb92ce6e210cb7939966e537021c1842", size = 2168459, upload-time = "2026-05-27T20:34:42.312Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, +] + +[[package]] +name = "tzlocal" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" }, +] + +[[package]] +name = "upstash-redis" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/ec/a2d5027239be97421f0bb26ca74b7be3f5597e4222431c5bb6ccc7c0f018/upstash_redis-1.7.0.tar.gz", hash = "sha256:19b46dff2d81b89da5cbfb23a38232f7ca3b9ba441b22a1df766c64972d936d1", size = 47336, upload-time = "2026-03-18T14:17:46.874Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/00/d4aede377301a66a751c8c240c1d989124afb42ee8a201d7f7406306dde3/upstash_redis-1.7.0-py3-none-any.whl", hash = "sha256:6c17b4cd1f88bd0d34d978b9cf13041032482f7dfb4cdf8e00f6f6277db0006a", size = 49746, upload-time = "2026-03-18T14:17:45.955Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.49.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/1f/fa18009dea8469069cca78a4e877a008ab78f08b064bfc9ab891579077ff/uvicorn-0.49.0.tar.gz", hash = "sha256:ebf4271aa580d9de97f93192d4595176df6e91f9aae919ca73e4fc07df1e66a3", size = 91284, upload-time = "2026-06-03T22:01:30.448Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/fa/e1388bbcf24ef3274f45c0c1c7b501fd14971037c1b6ee23610553307497/uvicorn-0.49.0-py3-none-any.whl", hash = "sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f", size = 71376, upload-time = "2026-06-03T22:01:29.037Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { 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.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, +] + +[[package]] +name = "venue404-api" +version = "0.0.1" +source = { editable = "." } +dependencies = [ + { name = "alembic" }, + { name = "apscheduler" }, + { name = "cloudinary" }, + { name = "email-validator" }, + { name = "fastapi" }, + { name = "httpx" }, + { name = "passlib", extra = ["bcrypt"] }, + { name = "pgvector" }, + { name = "pillow" }, + { name = "psycopg2-binary" }, + { name = "pydantic-settings" }, + { name = "python-jose", extra = ["cryptography"] }, + { name = "python-multipart" }, + { name = "rapidfuzz" }, + { name = "resend" }, + { name = "sqlalchemy" }, + { name = "stripe" }, + { name = "upstash-redis" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[package.optional-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-dotenv" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "alembic", specifier = ">=1.13.0" }, + { name = "apscheduler", specifier = ">=3.10.0" }, + { name = "cloudinary", specifier = ">=1.40.0" }, + { name = "email-validator", specifier = ">=2.2.0" }, + { name = "fastapi", specifier = ">=0.111.0" }, + { name = "httpx", specifier = ">=0.27.0" }, + { name = "passlib", extras = ["bcrypt"], specifier = ">=1.7.4" }, + { name = "pgvector", specifier = ">=0.3.0" }, + { name = "pillow", specifier = ">=10.0.0" }, + { name = "psycopg2-binary", specifier = ">=2.9.0" }, + { name = "pydantic-settings", specifier = ">=2.3.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.0" }, + { name = "pytest-dotenv", marker = "extra == 'dev'", specifier = ">=0.5.2" }, + { name = "python-jose", extras = ["cryptography"], specifier = ">=3.3.0" }, + { name = "python-multipart", specifier = ">=0.0.9" }, + { name = "rapidfuzz", specifier = ">=3.14.5" }, + { name = "resend", specifier = ">=2.0.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.5.0" }, + { name = "sqlalchemy", specifier = ">=2.0.0" }, + { name = "stripe", specifier = ">=9.0.0" }, + { name = "upstash-redis", specifier = ">=1.0.0" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.30.0" }, +] +provides-extras = ["dev"] + +[[package]] +name = "watchfiles" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115, upload-time = "2026-05-18T04:32:02.06Z" }, + { url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659, upload-time = "2026-05-18T04:30:50.951Z" }, + { url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207, upload-time = "2026-05-18T04:31:04.231Z" }, + { url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273, upload-time = "2026-05-18T04:31:50.377Z" }, + { url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927, upload-time = "2026-05-18T04:31:42.485Z" }, + { url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476, upload-time = "2026-05-18T04:31:03.071Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650, upload-time = "2026-05-18T04:30:12.701Z" }, + { url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398, upload-time = "2026-05-18T04:30:13.784Z" }, + { url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140, upload-time = "2026-05-18T04:31:52.111Z" }, + { url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259, upload-time = "2026-05-18T04:31:25.676Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859, upload-time = "2026-05-18T04:30:24.333Z" }, + { url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload-time = "2026-05-18T04:30:31.307Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload-time = "2026-05-18T04:32:10.745Z" }, + { url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload-time = "2026-05-18T04:30:22.23Z" }, + { url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730, upload-time = "2026-05-18T04:31:38.162Z" }, + { url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842, upload-time = "2026-05-18T04:30:27.051Z" }, + { url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989, upload-time = "2026-05-18T04:31:11.945Z" }, + { url = "https://files.pythonhosted.org/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978, upload-time = "2026-05-18T04:30:52.606Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248, upload-time = "2026-05-18T04:31:36.003Z" }, + { url = "https://files.pythonhosted.org/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847, upload-time = "2026-05-18T04:31:10.862Z" }, + { url = "https://files.pythonhosted.org/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974, upload-time = "2026-05-18T04:30:17.006Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782, upload-time = "2026-05-18T04:30:35.656Z" }, + { url = "https://files.pythonhosted.org/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182, upload-time = "2026-05-18T04:30:20.846Z" }, + { url = "https://files.pythonhosted.org/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841, upload-time = "2026-05-18T04:31:05.397Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028, upload-time = "2026-05-18T04:31:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183, upload-time = "2026-05-18T04:30:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059, upload-time = "2026-05-18T04:32:07.937Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186, upload-time = "2026-05-18T04:31:54.484Z" }, + { url = "https://files.pythonhosted.org/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031, upload-time = "2026-05-18T04:30:44.576Z" }, + { url = "https://files.pythonhosted.org/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205, upload-time = "2026-05-18T04:30:25.413Z" }, + { url = "https://files.pythonhosted.org/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892, upload-time = "2026-05-18T04:32:14.005Z" }, + { url = "https://files.pythonhosted.org/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867, upload-time = "2026-05-18T04:31:22.279Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217, upload-time = "2026-05-18T04:31:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458, upload-time = "2026-05-18T04:32:11.875Z" }, + { url = "https://files.pythonhosted.org/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707, upload-time = "2026-05-18T04:30:43.492Z" }, + { url = "https://files.pythonhosted.org/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663, upload-time = "2026-05-18T04:30:16.061Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537, upload-time = "2026-05-18T04:31:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194, upload-time = "2026-05-18T04:31:14.141Z" }, + { url = "https://files.pythonhosted.org/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194, upload-time = "2026-05-18T04:31:47.14Z" }, + { url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" }, + { url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" }, + { url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605, upload-time = "2026-05-18T04:30:23.312Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757, upload-time = "2026-05-18T04:30:47.358Z" }, + { url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672, upload-time = "2026-05-18T04:30:38.915Z" }, + { url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197, upload-time = "2026-05-18T04:30:09.914Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181, upload-time = "2026-05-18T04:30:14.829Z" }, + { url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109, upload-time = "2026-05-18T04:30:28.123Z" }, + { url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653, upload-time = "2026-05-18T04:31:49.304Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838, upload-time = "2026-05-18T04:31:06.497Z" }, + { url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108, upload-time = "2026-05-18T04:30:06.891Z" }, + { url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441, upload-time = "2026-05-18T04:32:12.901Z" }, + { url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684, upload-time = "2026-05-18T04:31:26.902Z" }, + { url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857, upload-time = "2026-05-18T04:32:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413, upload-time = "2026-05-18T04:31:07.911Z" }, + { url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409, upload-time = "2026-05-18T04:31:20.142Z" }, + { url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827, upload-time = "2026-05-18T04:32:06.219Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104, upload-time = "2026-05-18T04:31:56.034Z" }, + { url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360, upload-time = "2026-05-18T04:31:57.133Z" }, + { url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644, upload-time = "2026-05-18T04:30:57.33Z" }, + { url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771, upload-time = "2026-05-18T04:30:48.736Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494, upload-time = "2026-05-18T04:31:33.826Z" }, + { url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383, upload-time = "2026-05-18T04:31:15.596Z" }, + { url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093, upload-time = "2026-05-18T04:31:58.707Z" }, + { url = "https://files.pythonhosted.org/packages/92/b9/362702539275019a54dd2e94511b31a9b89c5f9e6a21966de7eb692549fc/watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374", size = 400109, upload-time = "2026-05-18T04:31:16.879Z" }, + { url = "https://files.pythonhosted.org/packages/8f/75/71d5ba62db781e5587bded1d944c675374bc4aa37ff33d5018d98e8b6538/watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65", size = 392167, upload-time = "2026-05-18T04:31:28.058Z" }, + { url = "https://files.pythonhosted.org/packages/3c/01/c66dd95d0423fe30d31820e2d1d5bda773764131bbb6ac0cb1cf303ac328/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69", size = 452372, upload-time = "2026-05-18T04:31:00.836Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/2fe99557e72f85627c6a8eed50d889e8d101623e060a22ad75b875cb932d/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579", size = 459596, upload-time = "2026-05-18T04:31:34.96Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/d4acfa0023367428ed48351b3b9b267893037b6cadae55620c61c24bcfd4/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7", size = 490869, upload-time = "2026-05-18T04:31:59.923Z" }, + { url = "https://files.pythonhosted.org/packages/a4/5f/3164cbdce06c9fb95c4f7b9e2f9760b5e2797af43a9ecc317ef42a23a278/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2", size = 571641, upload-time = "2026-05-18T04:32:00.948Z" }, + { url = "https://files.pythonhosted.org/packages/41/e6/85d3731c55e65cd7690f3f803d24c139588aaf863e4bf2148fe7a7fa1a19/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6", size = 464444, upload-time = "2026-05-18T04:30:34.298Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7d/562641012b8b09872742c3b8adf9629ec479fd78f8d68ae4a0c13da8add6/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4", size = 453593, upload-time = "2026-05-18T04:31:23.464Z" }, + { url = "https://files.pythonhosted.org/packages/56/fe/cb8ef3d6f929d14158fdaaad9925985b7310abc9384dcd4d82dd0016fb59/watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488", size = 465096, upload-time = "2026-05-18T04:31:30.384Z" }, + { url = "https://files.pythonhosted.org/packages/25/91/80908e835e100527a9267147b08c0eee1fa6ab0ffec15edc04d1d44885f7/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb", size = 630638, upload-time = "2026-05-18T04:30:49.89Z" }, + { url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +] diff --git a/apps/owner-portal/.env.example b/apps/owner-portal/.env.example new file mode 100644 index 000000000..8808b3ad6 --- /dev/null +++ b/apps/owner-portal/.env.example @@ -0,0 +1,8 @@ +# Supabase (browser client - anon key only) +VITE_SUPABASE_URL=https://your-project-ref.supabase.co +VITE_SUPABASE_ANON_KEY=your-supabase-anon-key + +# FastAPI backend +VITE_API_BASE_URL=http://localhost:8000 + +VITE_STRIPE_PUBLISHABLE_KEY= diff --git a/apps/owner-portal/index.html b/apps/owner-portal/index.html new file mode 100644 index 000000000..e01724b35 --- /dev/null +++ b/apps/owner-portal/index.html @@ -0,0 +1,16 @@ + + + + + + + +{helperText}
} ++ Everything you need to run and grow your space +
+List & Manage Venues
++ Publish your space, set availability, and control every detail +
+owners already listed
+Accept Bookings
+Live
+Track Revenue
+₹∞
+Get Discovered
+Reach customers actively searching for your type of space
+monthly searches
+Admin-reviewed access
+ +Secure payouts
+ +Full booking control
+{helperText}
} +{message}
+| {h} | + ))} +|||||||
|---|---|---|---|---|---|---|---|
| Venue | +Customer | +Event Date | +Booking Status | +Payment | +Guests | +Requested | ++ |
|---|---|---|---|---|---|---|---|
|
+
+
+
+
+
+ {booking.venue_name || 'Unknown Venue'}
+
+ |
+
+ {/* Customer */}
+
+
+ {booking.user_full_name || 'Guest'}
+
+ {booking.user_email && (
+ {booking.user_email}
+ )}
+ |
+
+ {/* Event Date */}
+ + {formatEventDate(booking)} + | + + {/* Booking Status */} +
+ |
+
+ {/* Payment */}
+
+ |
+
+ {/* Guests */}
+
+
+
+ |
+
+ {/* Requested */}
+ + {booking.created_at ? timeAgo(booking.created_at) : '—'} + | + + {/* Details link */} ++ e.stopPropagation()} + > + Details + + | +
Here is what needs your attention today.
++ Last {timeRange === '7D' ? '7 days — daily' : timeRange === '30D' ? '30 days — daily' : timeRange === '3M' ? '3 months — monthly' : timeRange === '12M' ? '12 months — monthly' : '6 months — monthly'} +
+{chartTotals.enquiries}
+{chartTotals.completed}
+{chartTotals.cancelled}
+No upcoming events
+Confirmed bookings will appear here
+{eventTypeLabel(event.event_type)}
+{event.venue_name}
+{formatEventDate(event.starts_at)}
+{event.guest_count} guests
+Total earnings after deductions
+Total booking value
+| {h} | + ))} +||||
|---|---|---|---|---|
| Date & Time | +Type | +Description | +Reference | +Amount | +
|---|---|---|---|---|
|
+ {formatDateTime(entry.created_at)}
+
+
+ |
+
+ {/* Type */}
+ + + {config.label} + + | + + {/* Description (Venue & User) */} +
+ {entry.venue_name && (
+
+
+ )}
+ {entry.user_full_name && (
+
+
+ )}
+ |
+
+ {/* Reference (Booking link + Stripe Ref) */}
+
+
+ View Booking
+
+ {entry.stripe_pi_ref && (
+
+ {entry.stripe_pi_ref.startsWith('pi_') ? 'Stripe PI' : 'Stripe Ref'}: {entry.stripe_pi_ref.substring(0, 10)}...
+
+ )}
+ |
+
+ {/* Amount */}
+
+
+ {isCredit ? '+' : '-'}{inr(entry.amount_paise)}
+
+
+ {entry.direction}
+
+ |
+
+
+ Your account must be approved before you can access the portal. +
++ {venue.rejection_reason} +
++ Your venue owner account is pending approval from our team. We'll reach out via email once + a decision has been made. +
+ ++ Applications are reviewed by our team. We'll email you once a decision is made. +
++ Please check your email to confirm your account. Once confirmed, log in — your application + will be reviewed by our team before you can access the owner portal. +
+ + Go to login + ++ Your venue owner application was reviewed and could not be approved at this time. You can + submit a new application for reconsideration. +
+ + {error && ( ++ For {booking.venue_name} • Created on {new Date(booking.created_at || Date.now()).toLocaleDateString()} +
++ {finalPayout !== null ? 'Final Owner Payout' : 'Projected Owner Payout'} +
++ {finalPayout !== null ? fmt(finalPayout) : fmt(ownerPayoutProjected)} +
+0 ? 'text-rose-900' : 'text-zinc-800'}`}> + {isForfeitCancelled && 'Balance Overdue — Deposit Forfeited'} + {isUserCancelled && refundAmount > 0 && 'User Cancelled — Refund Issued'} + {isUserCancelled && refundAmount === 0 && 'User Cancelled — No Refund'} + {isAdminCancelled && 'Admin Cancelled'} + {(s === 'hold_expired' || s === 'request_expired') && 'Booking Expired'} + {s === 'conflict_cancelled' && 'Cancelled — Conflict'} +
+0 ? 'text-rose-700/80' : 'text-zinc-500'}`}> + {isForfeitCancelled && `Customer missed the balance payment deadline. Advance of ${fmt(advanceDue)} is forfeited. Your net share after ${commissionPct}% commission: ${fmt(forfeitOwnerRetains)}.`} + {isUserCancelled && refundAmount > 0 && `${fmt(refundAmount)} was refunded to the customer based on your cancellation policy. You retain ${fmt(amountPaid - refundAmount)} minus platform commission.`} + {isUserCancelled && refundAmount === 0 && `No refund was issued to the customer based on your cancellation policy. You retain the full collected amount.`} + {isAdminCancelled && 'This booking was cancelled by a platform administrator.'} +
+ {booking.cancelled_at && ( ++ Cancelled {new Date(booking.cancelled_at).toLocaleString()} +
+ )} +Balance Payment Overdue
++ Due date was {booking.balance_due_date && new Date(booking.balance_due_date).toLocaleDateString()}. + {isOverdue && ` Marked overdue at ${new Date(booking.balance_overdue_at!).toLocaleString()}.`} + {' '}Customer has used {booking.deadline_extension_count} of 2 allowed extension(s). +
+ {booking.owner_action_deadline && ( ++ Action window closes: {new Date(booking.owner_action_deadline).toLocaleString()} +
+ )} + {isOverdue && ( +Payment Milestones
+ + {/* Advance */} +Ref: {booking.stripe_advance_payment_intent_id}
+ :Required to confirm booking
+ } +Ref: {booking.stripe_balance_payment_intent_id}
+ : booking.balance_due_date + ?+ Due {new Date(booking.balance_due_date).toLocaleDateString()}{isOverdue && ' — OVERDUE'} +
+ :Paid before event
+ } +Notes provided during booking
+Private to your team
+Please provide a reason for rejecting this booking. The user will be notified.
+Select a new due date for the balance payment. This gives the customer more time to pay.
+Choose how you want to process this cancellation. This action cannot be undone.
++ Set your standard operating hours for each day of the week. Uncheck the "Available" box if your venue is closed on a specific day. +
++ Block out specific dates and times when your venue will be unavailable for booking (e.g., for maintenance, private events, or holidays). Blocked dates will override your standard weekly schedule. +
+No upcoming blocked dates.
+Dates you block will appear here so you can easily unblock them later.
+This venue may have been deleted or doesn't exist.
+ + + +{error}
+Take your time to add photos, configure pricing, and review your policies. When you are fully satisfied and ready to accept bookings, submit it for review to make it live.
+Monthly Revenue
+Active Bookings
+Max Capacity
+Set weekend, peak-hour, and special-date percentage rules on top of your base price.
+Configure all aspects of your venue's listing and operations.
+{module.desc}
+No rule can ever push your price outside this range.
++ Example: a ₹{((venue.starting_price_paise ?? venue.hourly_rate_paise ?? 0) / 100).toLocaleString('en-IN')} base price + always stays between {minPct}% and {maxPct}% of that value. +
+ )} +Create a new dynamic pricing adjustment.
+Manage your active pricing rules ({rules.length}).
+No pricing rules yet. Your base price applies to every booking.
+See exactly how your rules apply to a specific date.
+{previewError}
} + + {preview && ( ++ ⚠️ A rule was capped by your price bounds for this quote. +
+ )} + {preview.breakdown.length > 0 && ( +Price Breakdown
+ {preview.breakdown.map((b, i) => ( +This page is under construction.
+