From a5f2e121c3a6d3486c6f2538bbfd11b419466156 Mon Sep 17 00:00:00 2001 From: Tiago Vasconcelos Date: Mon, 19 Oct 2026 12:12:10 +0100 Subject: [PATCH 1/4] Align event and ticket views --- Makefile | 3 + crud.py | 17 +++++ models.py | 15 +++- static/js/display.vue | 2 +- static/js/index.js | 2 +- static/js/register.js | 22 ++++-- static/js/register.vue | 4 - tests/test_events.py | 146 +++++++++++++++++++++++++++++++++++++ tests/test_registration.js | 75 +++++++++++++++++++ views_api.py | 36 ++++++--- 10 files changed, 294 insertions(+), 28 deletions(-) create mode 100644 tests/test_events.py create mode 100644 tests/test_registration.js diff --git a/Makefile b/Makefile index 0fac253..2e7c4a9 100644 --- a/Makefile +++ b/Makefile @@ -34,6 +34,9 @@ test: PYTHONUNBUFFERED=1 \ DEBUG=true \ uv run pytest + +test-js: + node --test tests/test_registration.js install-pre-commit-hook: @echo "Installing pre-commit hook to git" @echo "Uninstall the hook with uv run pre-commit uninstall" diff --git a/crud.py b/crud.py index 930915e..253a049 100644 --- a/crud.py +++ b/crud.py @@ -41,6 +41,23 @@ async def update_ticket(ticket: Ticket) -> Ticket: return ticket +async def register_ticket(ticket: Ticket) -> bool: + now = datetime.now(timezone.utc) + result = await db.execute( + f""" + UPDATE events.ticket SET registered = true, + reg_timestamp = {db.timestamp_placeholder("reg_timestamp")} + WHERE id = :id AND paid = true AND registered = false + """, + {"id": ticket.id, "reg_timestamp": now.timestamp()}, + ) + if result.rowcount != 1: + return False + ticket.registered = True + ticket.reg_timestamp = now + return True + + async def get_ticket(payment_hash: str) -> Ticket | None: return await db.fetchone( "SELECT * FROM events.ticket WHERE id = :id", diff --git a/models.py b/models.py index ac0d6b5..3b943e5 100644 --- a/models.py +++ b/models.py @@ -36,16 +36,19 @@ class TicketWave(BaseModel): price_per_ticket: float = Field(default=0, ge=0) -class EventExtra(BaseModel): - promo_codes: list[PromoCode] = Field(default_factory=list) +class PublicEventExtra(BaseModel): ticket_waves: list[TicketWave] = Field(default_factory=list) conditional: bool = False min_tickets: int = 1 email_notifications: bool = False nostr_notifications: bool = False + onchain_enabled: bool = False + + +class EventExtra(PublicEventExtra): + promo_codes: list[PromoCode] = Field(default_factory=list) notification_subject: str = "" notification_body: str = "" - onchain_enabled: bool = False onchain_wallet_id: str | None = None onchain_zeroconf: bool = False onchain_fasttrack: bool = False @@ -100,7 +103,7 @@ class PublicEvent(BaseModel): fiat_currency: str = "GBP" price_per_ticket: float banner: str | None - extra: EventExtra = Field(default_factory=EventExtra) + extra: PublicEventExtra = Field(default_factory=PublicEventExtra) class TicketExtra(BaseModel): @@ -168,6 +171,10 @@ class PublicTicket(BaseModel): reg_timestamp: datetime +class TicketRegistration(PublicTicket): + id: str + + class TicketPaymentRequest(BaseModel): payment_hash: str payment_request: str | None = None diff --git a/static/js/display.vue b/static/js/display.vue index 17a271f..9b70a54 100644 --- a/static/js/display.vue +++ b/static/js/display.vue @@ -9,7 +9,7 @@ >

-
+
diff --git a/static/js/index.js b/static/js/index.js index 0954da4..b7d535c 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -436,7 +436,7 @@ window.PageEvents = { .request( 'GET', '/events/api/v1/events?all_wallets=true', - this.g.user.wallets[0].inkey + this.g.user.wallets[0].adminkey ) .then(response => { this.events = response.data diff --git a/static/js/register.js b/static/js/register.js index 1c83c2b..7b9655a 100644 --- a/static/js/register.js +++ b/static/js/register.js @@ -9,12 +9,6 @@ window.PageEventsRegister = { label: this.$t('events.col_name'), field: 'name' }, - { - name: 'email', - align: 'left', - label: this.$t('email'), - field: 'email' - }, { name: 'id', align: 'left', @@ -62,9 +56,21 @@ window.PageEventsRegister = { }, decodeQR(res) { this.sendCamera.show = false - const value = res[0].rawValue.split('//')[1] + const value = res[0]?.rawValue?.match( + /^ticket:\/\/([A-Za-z0-9_-]+)$/ + )?.[1] + if (!value) { + Quasar.Notify.create({ + type: 'negative', + message: 'Invalid ticket QR code.' + }) + return + } LNbits.api - .request('PUT', `/events/api/v1/tickets/register/${value}`) + .request( + 'PUT', + `/events/api/v1/tickets/register/${value}?event_id=${encodeURIComponent(this.eventId)}` + ) .then(response => { this.saveScannedTicket(response.data) this.lastScan = {success: true, ticket: response.data} diff --git a/static/js/register.vue b/static/js/register.vue index 24bc242..0121d3b 100644 --- a/static/js/register.vue +++ b/static/js/register.vue @@ -34,10 +34,6 @@ {{ lastScan.ticket.name }} -
- - {{ lastScan.ticket.email }} -
{{ lastScan.ticket.paid }} diff --git a/tests/test_events.py b/tests/test_events.py new file mode 100644 index 0000000..edbe5e0 --- /dev/null +++ b/tests/test_events.py @@ -0,0 +1,146 @@ +import asyncio +import inspect +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +import pytest_asyncio +from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient +from lnbits.core.models import KeyType +from lnbits.db import Database +from lnbits.settings import settings + +from .. import crud, migrations, views_api +from ..models import CreateEvent, Event, EventExtra, PromoCode + + +@pytest.fixture +def event(): + today = datetime.now(timezone.utc) + return Event( + id="event", + wallet="wallet", + name="Event", + info="**Welcome**", + closing_date=(today + timedelta(days=1)).date().isoformat(), + event_start_date=today.date().isoformat(), + event_end_date=today.date().isoformat(), + currency="sat", + amount_tickets=10, + price_per_ticket=1000, + time=today, + extra=EventExtra( + promo_codes=[PromoCode(code="invite", discount_percent=50)], + notification_body="Organiser message", + ), + ) + + +@pytest_asyncio.fixture +async def database(tmp_path, monkeypatch): + monkeypatch.setattr(settings, "lnbits_data_folder", str(tmp_path)) + db = Database("ext_events") + monkeypatch.setattr(crud, "db", db) + for name, migrate in inspect.getmembers(migrations, inspect.isfunction): + if name.startswith("m"): + await migrate(db) + yield db + await db.engine.dispose() + + +@pytest.fixture +def app(): + app = FastAPI() + app.include_router(views_api.events_api_router, prefix="/events") + app.include_router(views_api.tickets_api_router, prefix="/events") + return app + + +def authorize(app, key_type): + wallet = SimpleNamespace(id="wallet", user="user") + info = SimpleNamespace(key_type=key_type, wallet=wallet) + + async def get_info(): + return info + + app.dependency_overrides[views_api.require_invoice_key] = get_info + app.dependency_overrides[views_api.require_admin_key] = get_info + + +@pytest.mark.asyncio +@pytest.mark.parametrize("key_type", [KeyType.invoice, KeyType.admin]) +@pytest.mark.parametrize("all_wallets", [False, True]) +async def test_event_listing(app, event, monkeypatch, key_type, all_wallets): + authorize(app, key_type) + get_events = AsyncMock(return_value=[event]) + monkeypatch.setattr(views_api, "get_events", get_events) + monkeypatch.setattr( + views_api, + "get_user", + AsyncMock(return_value=SimpleNamespace(wallet_ids=["wallet", "second"])), + ) + async with AsyncClient( + transport=ASGITransport(app), base_url="http://test" + ) as client: + response = await client.get(f"/events/api/v1/events?all_wallets={all_wallets}") + assert response.status_code == 200 + extra = response.json()[0]["extra"] + assert ("promo_codes" in extra) == (key_type == KeyType.admin) + assert ("notification_body" in extra) == (key_type == KeyType.admin) + get_events.assert_awaited_once_with( + ["wallet", "second"] if all_wallets else ["wallet"] + ) + + +@pytest.mark.asyncio +async def test_create_wallet_binding(app, event, monkeypatch): + authorize(app, KeyType.admin) + create = AsyncMock(return_value=event) + monkeypatch.setattr(views_api, "create_event", create) + data = CreateEvent(**event.dict()).dict() + async with AsyncClient( + transport=ASGITransport(app), base_url="http://test" + ) as client: + data["wallet"] = "other" + assert ( + await client.post("/events/api/v1/events", json=data) + ).status_code == 403 + create.assert_not_awaited() + data["wallet"] = "wallet" + assert ( + await client.post("/events/api/v1/events", json=data) + ).status_code == 200 + + +@pytest.mark.asyncio +async def test_ticket_registration(app, database): + ticket = await crud.create_ticket( + "ticket", + "wallet", + "event", + "Buyer", + "buyer@example.com", + {"refund_address": "private"}, + ) + ticket.paid = True + await crud.update_ticket(ticket) + async with AsyncClient( + transport=ASGITransport(app), base_url="http://test" + ) as client: + wrong = await client.put( + "/events/api/v1/tickets/register/ticket?event_id=other" + ) + assert wrong.status_code == 403 + responses = await asyncio.gather( + *[ + client.put("/events/api/v1/tickets/register/ticket?event_id=event") + for _ in range(2) + ] + ) + assert sum(r.status_code == 200 for r in responses) == 1 + result = next(r.json() for r in responses if r.status_code == 200) + assert result["registered"] is True + assert result["id"] == ticket.id + assert "email" not in result and "extra" not in result and "wallet" not in result diff --git a/tests/test_registration.js b/tests/test_registration.js new file mode 100644 index 0000000..5e87ebc --- /dev/null +++ b/tests/test_registration.js @@ -0,0 +1,75 @@ +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') +const test = require('node:test') +const vm = require('node:vm') + +function scanner() { + const requests = [] + const notices = [] + const context = { + window: {}, + encodeURIComponent, + LNbits: { + api: { + request: async (...args) => { + requests.push(args) + return {data: {id: 'ticket'}} + } + }, + utils: { + notifyApiError: error => { + throw error + } + } + }, + Quasar: {Notify: {create: notice => notices.push(notice)}} + } + vm.runInNewContext( + fs.readFileSync(path.join(__dirname, '../static/js/register.js'), 'utf8'), + context + ) + const state = { + sendCamera: {show: true}, + eventId: 'event?other=1#fragment', + saveScannedTicket() {} + } + return { + requests, + notices, + scan: rawValue => + context.window.PageEventsRegister.methods.decodeQR.call(state, [ + {rawValue} + ]) + } +} + +test('registration accepts ticket identifiers and encodes the event filter', async () => { + for (const id of ['short-Ticket_123', 'a'.repeat(64)]) { + const {requests, scan} = scanner() + scan(`ticket://${id}`) + await Promise.resolve() + assert.equal(requests.length, 1) + assert.equal( + requests[0][1], + `/events/api/v1/tickets/register/${id}?event_id=event%3Fother%3D1%23fragment` + ) + } +}) + +test('registration rejects malformed QR values before making a request', () => { + for (const value of [ + 'ticket://..', + 'ticket://a/b', + 'ticket://a?event_id=other', + 'ticket://a#fragment', + 'https://ticket', + 'ticket://', + '' + ]) { + const {requests, notices, scan} = scanner() + scan(value) + assert.equal(requests.length, 0) + assert.equal(notices[0].type, 'negative') + } +}) diff --git a/views_api.py b/views_api.py index c55ffa0..ccd1ce0 100644 --- a/views_api.py +++ b/views_api.py @@ -19,7 +19,7 @@ from lnbits.core.crud import get_user from lnbits.core.crud.assets import get_public_asset from lnbits.core.crud.wallets import get_wallet -from lnbits.core.models import WalletTypeInfo +from lnbits.core.models import KeyType, WalletTypeInfo from lnbits.core.models.payments import CreateInvoice from lnbits.core.services import create_payment_request from lnbits.db import Filters, Page @@ -50,8 +50,8 @@ get_tickets, get_tickets_paginated, purge_unpaid_tickets, + register_ticket, update_event, - update_ticket, ) from .models import ( CreateEvent, @@ -62,6 +62,7 @@ Ticket, TicketFilters, TicketPaymentRequest, + TicketRegistration, TicketResendResult, ensure_ticket_waves, get_active_ticket_waves, @@ -153,14 +154,17 @@ def make_qr_png(data: str, size: int = 235, border: int = 4) -> Image.Image: async def api_events( all_wallets: bool = Query(False), wallet: WalletTypeInfo = Depends(require_invoice_key), -) -> list[Event]: +) -> list[Event] | list[PublicEvent]: wallet_ids = [wallet.wallet.id] if all_wallets: user = await get_user(wallet.wallet.user) wallet_ids = user.wallet_ids if user else [] - return await get_events(wallet_ids) + events = await get_events(wallet_ids) + if wallet.key_type == KeyType.admin: + return events + return [PublicEvent(**event.dict()) for event in events] @events_api_router.get("/onchain/status") @@ -238,6 +242,10 @@ async def api_event_create( ) event = await update_event(event) else: + if data.wallet != wallet.wallet.id: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, detail="Not your wallet." + ) event = await create_event(data) return event @@ -784,8 +792,10 @@ async def api_ticket_resend_email( ) from exc -@tickets_api_router.put("/register/{ticket_id}") -async def api_event_register_ticket(ticket_id) -> Ticket: +@tickets_api_router.put("/register/{ticket_id}", response_model=TicketRegistration) +async def api_event_register_ticket( + ticket_id: str, event_id: str | None = None +) -> Ticket: ticket = await get_ticket(ticket_id) if not ticket: @@ -798,12 +808,18 @@ async def api_event_register_ticket(ticket_id) -> Ticket: status_code=HTTPStatus.FORBIDDEN, detail="Ticket not paid for." ) + if event_id and ticket.event != event_id: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, detail="Ticket is for another event." + ) + if ticket.registered is True: raise HTTPException( - status_code=HTTPStatus.FORBIDDEN, detail="Ticket already registered" + status_code=HTTPStatus.CONFLICT, detail="Ticket already registered" ) - ticket.registered = True - ticket.reg_timestamp = datetime.now(timezone.utc) - ticket = await update_ticket(ticket) + if not await register_ticket(ticket): + raise HTTPException( + status_code=HTTPStatus.CONFLICT, detail="Ticket already registered" + ) return ticket From 2817378c2f1c8966ddf5882e378c05704fd6a598 Mon Sep 17 00:00:00 2001 From: Tiago Vasconcelos Date: Mon, 19 Oct 2026 12:12:40 +0100 Subject: [PATCH 2/4] Update ticket delivery settings --- models.py | 1 - services.py | 22 +++++++----- tests/test_events.py | 80 ++++++++++++++++++++++++++++++++++++++++++-- views_api.py | 17 +++------- 4 files changed, 97 insertions(+), 23 deletions(-) diff --git a/models.py b/models.py index 3b943e5..5704681 100644 --- a/models.py +++ b/models.py @@ -113,7 +113,6 @@ class TicketExtra(BaseModel): sats_paid: int | None = None refund_address: str | None = None nostr_identifier: str | None = None - ticket_base_url: str | None = None email_notification_sent: bool = False nostr_notification_sent: bool = False refunded: bool = False diff --git a/services.py b/services.py index fbfac2a..980b8c7 100644 --- a/services.py +++ b/services.py @@ -6,6 +6,7 @@ from email.mime.text import MIMEText from html import escape from typing import Any +from urllib.parse import urlsplit import httpx from lnbits.core.models.users import UserNotifications @@ -121,9 +122,7 @@ async def _send_ticket_notification(ticket: Ticket) -> None: await _deliver_ticket_notifications(ticket, event) -async def resend_ticket_email_notification( - ticket: Ticket, base_url: str | None = None -) -> TicketResendResult: +async def resend_ticket_email_notification(ticket: Ticket) -> TicketResendResult: event = await get_event(ticket.event) if not event: raise ValueError("Event does not exist.") @@ -131,12 +130,19 @@ async def resend_ticket_email_notification( raise ValueError("Email notifications are not enabled.") if not ticket.email: raise ValueError("Ticket does not have an email address.") - if base_url: - ticket.extra.ticket_base_url = base_url.rstrip("/") - return await _deliver_ticket_notifications(ticket, event) +def get_event_base_url() -> str: + base_url = settings.lnbits_baseurl.rstrip("/") + parsed = urlsplit(base_url) + if parsed.scheme not in {"http", "https"} or not parsed.hostname: + raise ValueError("Configure LNBITS_BASEURL with an absolute HTTP or HTTPS URL.") + if parsed.query or parsed.fragment: + raise ValueError("LNBITS_BASEURL must not include a query or fragment.") + return base_url + + def _ticket_notification_message(ticket: Ticket, event: Event) -> tuple[str, str]: ticket_url = _ticket_url(ticket) subject = ( @@ -292,7 +298,7 @@ async def _send_ticket_email_notification( def _ticket_url(ticket: Ticket) -> str: - base_url = (ticket.extra.ticket_base_url or settings.lnbits_baseurl).rstrip("/") + base_url = get_event_base_url() return f"{base_url}/events/ticket/{ticket.id}" @@ -305,7 +311,7 @@ def _ticket_image_url(ticket: Ticket, event: Event) -> str | None: if not wave.use_ticket_image: return None - base_url = (ticket.extra.ticket_base_url or settings.lnbits_baseurl).rstrip("/") + base_url = get_event_base_url() return f"{base_url}/events/api/v1/qr/{ticket.id}" diff --git a/tests/test_events.py b/tests/test_events.py index edbe5e0..1bcf813 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -12,8 +12,8 @@ from lnbits.db import Database from lnbits.settings import settings -from .. import crud, migrations, views_api -from ..models import CreateEvent, Event, EventExtra, PromoCode +from .. import crud, migrations, services, views_api +from ..models import CreateEvent, Event, EventExtra, PromoCode, Ticket, TicketExtra @pytest.fixture @@ -144,3 +144,79 @@ async def test_ticket_registration(app, database): assert result["registered"] is True assert result["id"] == ticket.id assert "email" not in result and "extra" not in result and "wallet" not in result + + +def test_notification_urls(event, monkeypatch): + monkeypatch.setattr(settings, "lnbits_baseurl", "https://example.com") + ticket = Ticket( + id="ticket", + wallet="wallet", + event=event.id, + name="Buyer", + email="buyer@example.com", + registered=False, + paid=True, + time=event.time, + reg_timestamp=event.time, + extra=TicketExtra.parse_obj( + { + "ticket_base_url": "https://untrusted.example", + "ticket_wave_id": "primary", + } + ), + ) + assert "ticket_base_url" not in ticket.extra.dict() + assert services._ticket_url(ticket) == "https://example.com/events/ticket/ticket" + event.extra.ticket_waves = services.ensure_ticket_waves(event) + event.extra.ticket_waves[0].use_ticket_image = True + assert ( + services._ticket_image_url(ticket, event) + == "https://example.com/events/api/v1/qr/ticket" + ) + + +@pytest.mark.parametrize( + "base_url", ["", "/relative", "ftp://example.com", "https://example.com/?x=1"] +) +def test_event_base_url_validation(monkeypatch, base_url): + monkeypatch.setattr(settings, "lnbits_baseurl", base_url) + with pytest.raises(ValueError, match="LNBITS_BASEURL"): + services.get_event_base_url() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("base_url", ["", "https://tickets.example/lnbits/"]) +async def test_onchain_links_use_configuration(app, event, monkeypatch, base_url): + event.extra.onchain_enabled = True + event.extra.onchain_wallet_id = "onchain-wallet" + monkeypatch.setattr(settings, "lnbits_baseurl", base_url) + monkeypatch.setattr(views_api, "get_event", AsyncMock(return_value=event)) + monkeypatch.setattr( + views_api, "get_wallet", AsyncMock(return_value=SimpleNamespace(inkey="key")) + ) + charge = AsyncMock(return_value={"id": "charge"}) + save = AsyncMock() + monkeypatch.setattr(views_api, "create_satspay_charge", charge) + monkeypatch.setattr(views_api, "create_ticket", save) + async with AsyncClient( + transport=ASGITransport(app), base_url="https://untrusted.example" + ) as client: + response = await client.post( + f"/events/api/v1/tickets/{event.id}", + json={ + "name": "Buyer", + "email": "buyer@example.com", + "payment_method": "onchain", + }, + ) + if not base_url: + assert response.status_code == 400 + assert "LNBITS_BASEURL" in response.json()["detail"] + charge.assert_not_awaited() + save.assert_not_awaited() + else: + assert response.status_code == 200 + data = charge.call_args.kwargs["data"] + assert data["completelink"].startswith(base_url + "events/ticket/") + assert "untrusted.example" not in data["webhook"] + assert "ticket_base_url" not in save.call_args.kwargs["extra"] diff --git a/views_api.py b/views_api.py index ccd1ce0..16d86ab 100644 --- a/views_api.py +++ b/views_api.py @@ -11,7 +11,6 @@ Depends, HTTPException, Query, - Request, WebSocket, WebSocketDisconnect, ) @@ -71,6 +70,7 @@ create_satspay_charge, fetch_watchonly_config, fetch_watchonly_wallets, + get_event_base_url, get_satspay_charge, refund_tickets, resend_ticket_email_notification, @@ -408,9 +408,7 @@ async def api_ticket_qr(ticket_id: str): @tickets_api_router.post("/{event_id}") -async def api_ticket_create( - event_id: str, data: CreateTicket, request: Request -) -> TicketPaymentRequest: +async def api_ticket_create(event_id: str, data: CreateTicket) -> TicketPaymentRequest: event = await get_event(event_id) if not event: raise HTTPException( @@ -541,15 +539,15 @@ async def api_ticket_create( ) ticket_id = urlsafe_short_hash() - base_url = str(request.base_url).rstrip("/") # Use internal address for webhook — SatsPay calls it server-side and # cannot reach the public domain from within the container. internal_base = f"http://{settings.host}:{settings.port}" webhook_url = ( f"{internal_base}/events/api/v1/tickets/{ticket_id}/satspay-webhook" ) - complete_url = f"{base_url}/events/ticket/{ticket_id}" try: + base_url = get_event_base_url() + complete_url = f"{base_url}/events/ticket/{ticket_id}" charge = await create_satspay_charge( api_key=wallet_record.inkey, data={ @@ -583,7 +581,6 @@ async def api_ticket_create( "ticket_wave_title": selected_wave.title, "refund_address": refund_address, "nostr_identifier": nostr_identifier, - "ticket_base_url": base_url, "sats_paid": onchain_amount_sat, "onchain": True, "satspay_charge_id": charge["id"], @@ -621,7 +618,6 @@ async def api_ticket_create( "ticket_wave_title": selected_wave.title, "refund_address": refund_address, "nostr_identifier": nostr_identifier, - "ticket_base_url": str(request.base_url).rstrip("/"), "sats_paid": payment.sat, "onchain": False, }, @@ -764,7 +760,6 @@ async def api_ticket_onchain_confirm( @tickets_api_router.post("/{ticket_id}/resend-email", response_model=TicketResendResult) async def api_ticket_resend_email( ticket_id: str, - request: Request, wallet: WalletTypeInfo = Depends(require_admin_key), ) -> TicketResendResult: ticket = await get_ticket(ticket_id) @@ -783,9 +778,7 @@ async def api_ticket_resend_email( ) try: - return await resend_ticket_email_notification( - ticket, str(request.base_url).rstrip("/") - ) + return await resend_ticket_email_notification(ticket) except ValueError as exc: raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, detail=str(exc) From b17a5001ab6f48d5030e9be79ac1c4c4e316b192 Mon Sep 17 00:00:00 2001 From: Tiago Vasconcelos Date: Mon, 19 Oct 2026 12:12:54 +0100 Subject: [PATCH 3/4] Update event processing --- __init__.py | 4 +- crud.py | 46 ++++ migrations.py | 17 ++ models.py | 7 + services.py | 100 ++++++++- tasks.py | 12 +- tests/test_events.py | 509 ++++++++++++++++++++++++++++++++++++++++++- views_api.py | 10 +- 8 files changed, 682 insertions(+), 23 deletions(-) diff --git a/__init__.py b/__init__.py index 827b8d5..f31e575 100644 --- a/__init__.py +++ b/__init__.py @@ -4,7 +4,7 @@ from loguru import logger from .crud import db -from .tasks import wait_for_paid_invoices +from .tasks import close_expired_events, wait_for_paid_invoices from .views import events_generic_router from .views_api import events_api_router, qr_api_router, tickets_api_router @@ -37,6 +37,8 @@ def events_start(): task = create_permanent_unique_task("ext_events", wait_for_paid_invoices) scheduled_tasks.append(task) + task = create_permanent_unique_task("ext_events_close", close_expired_events) + scheduled_tasks.append(task) __all__ = ["db", "events_ext", "events_start", "events_static_files", "events_stop"] diff --git a/crud.py b/crud.py index 253a049..2783a30 100644 --- a/crud.py +++ b/crud.py @@ -3,6 +3,7 @@ from lnbits.db import Database, Filters, Page from lnbits.helpers import urlsafe_short_hash +from sqlalchemy.exc import IntegrityError # type: ignore[import-untyped] from .models import ( CreateEvent, @@ -10,6 +11,7 @@ Ticket, TicketExtra, TicketFilters, + TicketRefund, sync_event_ticket_waves, ) @@ -58,6 +60,23 @@ async def register_ticket(ticket: Ticket) -> bool: return True +async def get_ticket_refund(ticket_id: str) -> TicketRefund | None: + return await db.fetchone( + "SELECT * FROM events.refunds WHERE id = :id", {"id": ticket_id}, TicketRefund + ) + + +async def create_ticket_refund(refund: TicketRefund) -> TicketRefund: + try: + await db.insert("events.refunds", refund) + except IntegrityError: + existing = await get_ticket_refund(refund.id) + if not existing: + raise ValueError("Refund invoice is already assigned.") from None + return existing + return refund + + async def get_ticket(payment_hash: str) -> Ticket | None: return await db.fetchone( "SELECT * FROM events.ticket WHERE id = :id", @@ -156,6 +175,33 @@ async def get_events(wallet_ids: str | list[str]) -> list[Event]: return [cast(Event, sync_event_ticket_waves(event)) for event in events] +async def get_closed_events() -> list[Event]: + return await db.fetchall( + """ + SELECT * FROM events.events WHERE close_processed = false + AND (canceled = true OR closing_date < :today) + """, + {"today": datetime.now(timezone.utc).date().isoformat()}, + Event, + ) + + +async def mark_event_closed(event: Event) -> None: + await db.execute( + """ + UPDATE events.events SET close_processed = true + WHERE id = :id AND sold = :sold AND canceled = :canceled + AND closing_date = :closing_date + """, + { + "id": event.id, + "sold": event.sold, + "canceled": event.canceled, + "closing_date": event.closing_date, + }, + ) + + async def delete_event(event_id: str) -> None: await db.execute("DELETE FROM events.events WHERE id = :id", {"id": event_id}) diff --git a/migrations.py b/migrations.py index 512540d..57a03c7 100644 --- a/migrations.py +++ b/migrations.py @@ -195,3 +195,20 @@ async def m008_add_fiat_currency(db): ALTER TABLE events.events ADD COLUMN fiat_currency TEXT NOT NULL DEFAULT 'GBP'; """) + + +async def m009_add_ticket_refunds(db): + await db.execute(""" + CREATE TABLE events.refunds ( + id TEXT PRIMARY KEY, + payment_request TEXT NOT NULL, + payment_hash TEXT NOT NULL UNIQUE + ); + """) + + +async def m010_add_close_processed(db): + await db.execute(""" + ALTER TABLE events.events + ADD COLUMN close_processed BOOLEAN NOT NULL DEFAULT FALSE; + """) diff --git a/models.py b/models.py index 5704681..b1cccae 100644 --- a/models.py +++ b/models.py @@ -77,6 +77,7 @@ class Event(BaseModel): info: str closing_date: str canceled: bool = False + close_processed: bool = False event_start_date: str event_end_date: str currency: str @@ -174,6 +175,12 @@ class TicketRegistration(PublicTicket): id: str +class TicketRefund(BaseModel): + id: str + payment_request: str + payment_hash: str + + class TicketPaymentRequest(BaseModel): payment_hash: str payment_request: str | None = None diff --git a/services.py b/services.py index 980b8c7..942584e 100644 --- a/services.py +++ b/services.py @@ -2,6 +2,7 @@ import smtplib from asyncio.tasks import create_task +from datetime import datetime, timezone from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from html import escape @@ -9,16 +10,23 @@ from urllib.parse import urlsplit import httpx +from bolt11 import decode as bolt11_decode +from lnbits.core.crud.payments import get_standalone_payment from lnbits.core.models.users import UserNotifications +from lnbits.core.services.lnurl import get_pr_from_lnurl from lnbits.core.services.notifications import send_user_notification +from lnbits.core.services.payments import pay_invoice, update_pending_payment from lnbits.helpers import is_valid_email_address from lnbits.settings import settings -from lnurl import execute from loguru import logger from .crud import ( + create_ticket_refund, + get_closed_events, get_event, get_event_tickets, + get_ticket_refund, + mark_event_closed, purge_unpaid_tickets, update_event, update_ticket, @@ -27,6 +35,7 @@ Event, NotificationDeliveryResult, Ticket, + TicketRefund, TicketResendResult, ensure_ticket_waves, ) @@ -94,6 +103,7 @@ async def set_ticket_paid(ticket: Ticket) -> Ticket: event = await get_event(ticket.event) assert event, "Couldn't get event from ticket being paid" event.sold += 1 + event.close_processed = False ticket_waves = event.extra.ticket_waves or [] if ticket_waves: selected_wave = next( @@ -315,7 +325,7 @@ def _ticket_image_url(ticket: Ticket, event: Event) -> str | None: return f"{base_url}/events/api/v1/qr/{ticket.id}" -async def refund_tickets(event_id: str): +async def refund_tickets(event_id: str) -> bool: """ Refund tickets for an event that has not met the minimum ticket requirement. This function should be called when the event is closed and the minimum ticket @@ -324,19 +334,87 @@ async def refund_tickets(event_id: str): await purge_unpaid_tickets(event_id) tickets = await get_event_tickets(event_id) - if not tickets: - return - + complete = True for ticket in tickets: + if not ticket.paid: + complete = False + continue if ticket.extra.refunded: continue - if ticket.paid and ticket.extra.refund_address and ticket.extra.sats_paid: + if ticket.extra.refund_address and ticket.extra.sats_paid: try: - res = await execute( - ticket.extra.refund_address, str(ticket.extra.sats_paid) + refund = await get_ticket_refund(ticket.id) + if not refund: + payment_request = await get_pr_from_lnurl( + ticket.extra.refund_address, ticket.extra.sats_paid * 1000 + ) + refund = await create_ticket_refund( + TicketRefund( + id=ticket.id, + payment_request=payment_request, + payment_hash=bolt11_decode(payment_request).payment_hash, + ) + ) + + # Keep the same invoice across retries, including uncertain outcomes. + payment = await get_standalone_payment( + refund.payment_hash, wallet_id=ticket.wallet ) - if res: + if payment: + if ( + not payment.is_out + or payment.extra.get("ticket_id") != ticket.id + ): + raise ValueError("Refund payment does not match ticket.") + if not payment.success: + payment = await update_pending_payment(payment) + else: + payment = await pay_invoice( + wallet_id=ticket.wallet, + payment_request=refund.payment_request, + max_sat=ticket.extra.sats_paid, + tag="events_refund", + extra={"ticket_id": ticket.id}, + ) + if payment.success: ticket.extra.refunded = True await update_ticket(ticket) - except Exception as e: - logger.error(f"Error refunding ticket {ticket.id}: {e}") + else: + complete = False + if payment.failed: + logger.warning(f"Refund for ticket {ticket.id} needs review.") + except Exception as exc: + complete = False + logger.warning( + f"Could not complete refund for ticket {ticket.id}: " + f"{type(exc).__name__}: {exc}" + ) + return complete + + +async def close_events() -> None: + today = datetime.now(timezone.utc).date().isoformat() + for event in await get_closed_events(): + try: + if ( + not event.canceled + and event.closing_date < today + and event.extra.conditional + and event.sold < event.extra.min_tickets + ): + event.canceled = True + await update_event(event) + if event.canceled: + complete = await refund_tickets(event.id) + elif event.closing_date < today: + await purge_unpaid_tickets(event.id) + tickets = await get_event_tickets(event.id) + complete = all(ticket.paid for ticket in tickets) + else: + continue + if complete: + await mark_event_closed(event) + except Exception as exc: + logger.warning( + f"Could not close event {event.id}: {type(exc).__name__}: {exc}" + ) diff --git a/tasks.py b/tasks.py index 1d30dce..c3d3493 100644 --- a/tasks.py +++ b/tasks.py @@ -6,11 +6,21 @@ from .crud import get_ticket from .models import Ticket -from .services import send_ticket_notification_in_background, set_ticket_paid +from .services import ( + close_events, + send_ticket_notification_in_background, + set_ticket_paid, +) payment_listeners: dict[str, list[asyncio.Queue[Ticket]]] = {} +async def close_expired_events() -> None: + while True: + await close_events() + await asyncio.sleep(3600) + + def register_payment_listener(payment_hash, queue: asyncio.Queue[Ticket]) -> None: if payment_hash not in payment_listeners: payment_listeners[payment_hash] = [] diff --git a/tests/test_events.py b/tests/test_events.py index 1bcf813..431c4ae 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -1,19 +1,48 @@ import asyncio import inspect +import json from datetime import datetime, timedelta, timezone from types import SimpleNamespace -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, Mock import pytest import pytest_asyncio from fastapi import FastAPI from httpx import ASGITransport, AsyncClient -from lnbits.core.models import KeyType +from lnbits import wallets +from lnbits.core import migrations as core_migrations +from lnbits.core.crud import ( + create_account, + create_wallet, + get_payments, + get_standalone_payment, + get_wallet, +) +from lnbits.core.db import db as core_db +from lnbits.core.helpers import run_migration +from lnbits.core.models import CreateInvoice, KeyType +from lnbits.core.services import ( + create_payment_request, + pay_invoice, + update_wallet_balance, +) +from lnbits.core.services import lnurl as core_lnurl from lnbits.db import Database from lnbits.settings import settings +from lnbits.task_manager import task_manager +from lnbits.wallets.fake import FakeWallet +from lnurl import encode -from .. import crud, migrations, services, views_api -from ..models import CreateEvent, Event, EventExtra, PromoCode, Ticket, TicketExtra +from .. import crud, migrations, services, tasks, views_api +from ..models import ( + CreateEvent, + Event, + EventExtra, + PromoCode, + Ticket, + TicketExtra, + TicketRefund, +) @pytest.fixture @@ -50,6 +79,29 @@ async def database(tmp_path, monkeypatch): await db.engine.dispose() +@pytest_asyncio.fixture +async def payment_database(database, monkeypatch): + db = Database("database") + # Core modules share this database object; point it at the temporary database. + for name in ("engine", "path", "lock"): + monkeypatch.setattr(core_db, name, getattr(db, name)) + backend = FakeWallet() + monkeypatch.setattr(wallets, "funding_source", backend) + monkeypatch.setattr(settings, "lnbits_only_allow_incoming_payments", False) + monkeypatch.setattr(settings, "lnbits_service_fee", 0) + monkeypatch.setattr(settings, "lnbits_exchange_rate_providers", []) + monkeypatch.setattr(task_manager, "tasks", []) + monkeypatch.setattr(task_manager, "internal_invoice_queue", asyncio.Queue()) + async with core_db.connect() as conn: + await run_migration(conn, core_migrations, "core") + yield backend + pending_tasks = [task.task for task in task_manager.tasks] + for task in pending_tasks: + task.cancel() + await asyncio.gather(*pending_tasks, return_exceptions=True) + await db.engine.dispose() + + @pytest.fixture def app(): app = FastAPI() @@ -94,6 +146,27 @@ async def test_event_listing(app, event, monkeypatch, key_type, all_wallets): ) +@pytest.mark.asyncio +async def test_public_event_is_read_only(app, event, monkeypatch): + monkeypatch.setattr(views_api, "get_event", AsyncMock(return_value=event)) + update = AsyncMock() + refund = AsyncMock() + monkeypatch.setattr(views_api, "update_event", update) + monkeypatch.setattr(views_api, "refund_tickets", refund) + async with AsyncClient( + transport=ASGITransport(app), base_url="http://test" + ) as client: + response = await client.get("/events/api/v1/events/event") + assert response.status_code == 200 + assert "promo_codes" not in response.json()["extra"] + event.closing_date = "2000-01-01" + event.extra.conditional = True + response = await client.get("/events/api/v1/events/event") + assert response.status_code == 410 + update.assert_not_awaited() + refund.assert_not_awaited() + + @pytest.mark.asyncio async def test_create_wallet_binding(app, event, monkeypatch): authorize(app, KeyType.admin) @@ -146,6 +219,205 @@ async def test_ticket_registration(app, database): assert "email" not in result and "extra" not in result and "wallet" not in result +@pytest.mark.asyncio +async def test_refund_invoice_assignment(database): + first = TicketRefund(id="ticket", payment_hash="hash", payment_request="invoice") + second = TicketRefund(id="ticket", payment_hash="other", payment_request="other") + results = await asyncio.gather( + crud.create_ticket_refund(first), crud.create_ticket_refund(second) + ) + assert results[0] == results[1] + duplicate = TicketRefund( + id="another", + payment_hash=results[0].payment_hash, + payment_request=results[0].payment_request, + ) + with pytest.raises(ValueError, match="already assigned"): + await crud.create_ticket_refund(duplicate) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("state", ["pending", "failed", "exception"]) +async def test_refund_completion(database, monkeypatch, state): + ticket = await crud.create_ticket( + "ticket", + "wallet", + "event", + "Buyer", + "buyer@example.com", + {"refund_address": "buyer@example.com", "sats_paid": 1000}, + ) + ticket.paid = True + await crud.update_ticket(ticket) + fetch = AsyncMock(return_value="invoice") + monkeypatch.setattr(services, "get_pr_from_lnurl", fetch) + monkeypatch.setattr( + services, "bolt11_decode", lambda _: SimpleNamespace(payment_hash="hash") + ) + monkeypatch.setattr( + services, "get_standalone_payment", AsyncMock(return_value=None) + ) + payment = SimpleNamespace(success=False, failed=state == "failed") + pay = AsyncMock(return_value=payment) + if state == "exception": + pay.side_effect = RuntimeError("uncertain outcome") + monkeypatch.setattr(services, "pay_invoice", pay) + await services.refund_tickets("event") + fetch.assert_awaited_once_with("buyer@example.com", 1000000) + pay.assert_awaited_once_with( + wallet_id="wallet", + payment_request="invoice", + max_sat=1000, + tag="events_refund", + extra={"ticket_id": "ticket"}, + ) + stored = await crud.get_ticket("ticket") + assert stored + assert not stored.extra.refunded + assert await crud.get_ticket_refund("ticket") is not None + + +@pytest.mark.asyncio +@pytest.mark.parametrize("state", ["pending", "failed"]) +async def test_existing_refund_is_reconciled(database, monkeypatch, state): + ticket = await crud.create_ticket( + "ticket", + "wallet", + "event", + "Buyer", + "buyer@example.com", + {"refund_address": "buyer@example.com", "sats_paid": 1000}, + ) + ticket.paid = True + await crud.update_ticket(ticket) + await crud.create_ticket_refund( + TicketRefund(id="ticket", payment_hash="hash", payment_request="invoice") + ) + payment = SimpleNamespace( + is_out=True, + success=False, + failed=state == "failed", + extra={"ticket_id": "ticket"}, + ) + monkeypatch.setattr( + services, "get_standalone_payment", AsyncMock(return_value=payment) + ) + monkeypatch.setattr( + services, "update_pending_payment", AsyncMock(return_value=payment) + ) + pay = AsyncMock() + fetch = AsyncMock() + monkeypatch.setattr(services, "pay_invoice", pay) + monkeypatch.setattr(services, "get_pr_from_lnurl", fetch) + await services.refund_tickets("event") + pay.assert_not_awaited() + fetch.assert_not_awaited() + stored = await crud.get_ticket("ticket") + assert stored + assert not stored.extra.refunded + + +@pytest.mark.asyncio +async def test_refund_resumes_after_interruption(database, monkeypatch): + ticket = await crud.create_ticket( + "ticket", + "wallet", + "event", + "Buyer", + "buyer@example.com", + {"refund_address": "buyer@example.com", "sats_paid": 1000}, + ) + ticket.paid = True + await crud.update_ticket(ticket) + fetch = AsyncMock(return_value="invoice") + pay = AsyncMock(side_effect=TimeoutError("Payment outcome unknown")) + pending = SimpleNamespace( + is_out=True, success=False, extra={"ticket_id": ticket.id} + ) + reconcile = AsyncMock(return_value=SimpleNamespace(success=True, failed=False)) + monkeypatch.setattr(services, "get_pr_from_lnurl", fetch) + monkeypatch.setattr( + services, "bolt11_decode", lambda _: SimpleNamespace(payment_hash="hash") + ) + monkeypatch.setattr(services, "pay_invoice", pay) + monkeypatch.setattr( + services, "get_standalone_payment", AsyncMock(side_effect=[None, pending]) + ) + monkeypatch.setattr(services, "update_pending_payment", reconcile) + + await services.refund_tickets("event") + stored = await crud.get_ticket(ticket.id) + assert stored and not stored.extra.refunded + refund = await crud.get_ticket_refund(ticket.id) + assert refund and refund.payment_request == "invoice" + + await services.refund_tickets("event") + fetch.assert_awaited_once() + pay.assert_awaited_once() + reconcile.assert_awaited_once_with(pending) + stored = await crud.get_ticket(ticket.id) + assert stored and stored.extra.refunded + assert await crud.get_ticket_refund(ticket.id) == refund + + +@pytest.mark.asyncio +async def test_close_events(event, monkeypatch): + event.closing_date = "2000-01-01" + event.extra.conditional = True + monkeypatch.setattr(services, "get_closed_events", AsyncMock(return_value=[event])) + update = AsyncMock() + refund = AsyncMock(return_value=True) + monkeypatch.setattr(services, "update_event", update) + monkeypatch.setattr(services, "refund_tickets", refund) + mark = AsyncMock() + monkeypatch.setattr(services, "mark_event_closed", mark) + await services.close_events() + assert event.canceled + update.assert_awaited_once_with(event) + refund.assert_awaited_once_with(event.id) + mark.assert_awaited_once_with(event) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("days_ago", "conditional", "sold", "canceled"), + [ + (0, True, 0, False), + (1, True, 5, False), + (1, False, 0, False), + (1, False, 0, True), + ], + ids=["closing-today", "minimum-met", "unconditional", "already-canceled"], +) +async def test_close_events_boundaries( + event, monkeypatch, days_ago, conditional, sold, canceled +): + event.closing_date = ( + datetime.now(timezone.utc).date() - timedelta(days=days_ago) + ).isoformat() + event.extra.conditional = conditional + event.extra.min_tickets = 5 + event.sold = sold + event.canceled = canceled + monkeypatch.setattr(services, "get_closed_events", AsyncMock(return_value=[event])) + update = AsyncMock() + refund = AsyncMock(return_value=True) + monkeypatch.setattr(services, "update_event", update) + monkeypatch.setattr(services, "refund_tickets", refund) + monkeypatch.setattr(services, "purge_unpaid_tickets", AsyncMock()) + monkeypatch.setattr(services, "get_event_tickets", AsyncMock(return_value=[])) + monkeypatch.setattr(services, "mark_event_closed", AsyncMock()) + + await services.close_events() + + assert event.canceled == canceled + update.assert_not_awaited() + if canceled: + refund.assert_awaited_once_with(event.id) + else: + refund.assert_not_awaited() + + def test_notification_urls(event, monkeypatch): monkeypatch.setattr(settings, "lnbits_baseurl", "https://example.com") ticket = Ticket( @@ -175,6 +447,235 @@ def test_notification_urls(event, monkeypatch): ) +@pytest.mark.asyncio +async def test_refund_destination_policy(database, monkeypatch): + monkeypatch.setattr(settings, "lnbits_lnurl_allow_private_ips", False) + ticket = await crud.create_ticket( + "ticket", + "wallet", + "event", + "Buyer", + "buyer@example.com", + {"refund_address": str(encode("http://127.0.0.1/private")), "sats_paid": 1000}, + ) + ticket.paid = True + await crud.update_ticket(ticket) + send = AsyncMock() + pay = AsyncMock() + monkeypatch.setattr(core_lnurl, "_send_lnurl_request", send) + monkeypatch.setattr(services, "pay_invoice", pay) + await services.refund_tickets("event") + send.assert_not_awaited() + pay.assert_not_awaited() + assert await crud.get_ticket_refund("ticket") is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize("internal", [True, False], ids=["internal", "fake-backend"]) +async def test_refund_payment_flow(payment_database, app, event, monkeypatch, internal): + account = await create_account() + organiser = await create_wallet(user_id=account.id) + buyer_account = await create_account() + buyer = await create_wallet(user_id=buyer_account.id) + await update_wallet_balance(buyer, 2000) + data = CreateEvent(**event.dict()) + data.wallet = organiser.id + event = await crud.create_event(data) + async with AsyncClient( + transport=ASGITransport(app), base_url="http://test" + ) as client: + response = await client.post( + f"/events/api/v1/tickets/{event.id}", + json={ + "name": "Buyer", + "email": "buyer@example.com", + "refund_address": str(encode("https://1.1.1.1/refund")), + }, + ) + assert response.status_code == 200 + purchase = response.json() + await pay_invoice(wallet_id=buyer.id, payment_request=purchase["payment_request"]) + incoming = await get_standalone_payment( + purchase["payment_hash"], incoming=True, wallet_id=organiser.id + ) + assert incoming and incoming.success + await tasks.on_invoice_paid(incoming) + ticket = await crud.get_ticket(purchase["payment_hash"]) + assert ticket and ticket.paid + paid_event = await crud.get_event(event.id) + assert paid_event and paid_event.sold == 1 and paid_event.amount_tickets == 9 + if internal: + invoice = await create_payment_request(buyer.id, CreateInvoice(amount=1000)) + payment_request = invoice.bolt11 + payment_hash = invoice.payment_hash + else: + response = await payment_database.create_invoice(amount=1000) + assert response.ok and response.payment_request and response.checking_id + payment_request = response.payment_request + payment_hash = response.checking_id + + async def lnurl_response(url, **kwargs): + if url.path == "/refund": + payload = { + "tag": "payRequest", + "callback": "https://1.1.1.1/invoice", + "minSendable": 1000000, + "maxSendable": 1000000, + "metadata": '[["text/plain","Ticket refund"]]', + } + else: + assert url.path == "/invoice" + assert url.params["amount"] == "1000000" + payload = {"pr": payment_request, "routes": []} + return 200, {}, json.dumps(payload).encode() + + monkeypatch.setattr(core_lnurl, "_send_lnurl_request", lnurl_response) + + async def organiser_key(): + return SimpleNamespace(key_type=KeyType.admin, wallet=organiser) + + app.dependency_overrides[views_api.require_admin_key] = organiser_key + async with AsyncClient( + transport=ASGITransport(app), base_url="http://test" + ) as client: + responses = await asyncio.gather( + client.put(f"/events/api/v1/events/{event.id}/cancel"), + client.put(f"/events/api/v1/events/{event.id}/cancel"), + ) + assert all(response.status_code == 200 for response in responses) + if not internal: + stored = await crud.get_ticket(ticket.id) + assert stored and not stored.extra.refunded + refund = await crud.get_ticket_refund(ticket.id) + assert refund and refund.payment_hash == payment_hash + assert await get_payments(wallet_id=organiser.id, outgoing=True) == [] + await services.close_events() + waiting = await crud.get_event(event.id) + assert waiting and not waiting.close_processed + # External payments need a routing reserve, even when the final fee is zero. + funded_organiser = await get_wallet(organiser.id) + assert funded_organiser + await update_wallet_balance(funded_organiser, 100) + await services.close_events() + + stored = await crud.get_ticket(ticket.id) + assert stored and stored.extra.refunded + closed = await crud.get_event(event.id) + assert closed and closed.close_processed + assert await crud.get_closed_events() == [] + refund = await crud.get_ticket_refund(ticket.id) + assert refund and refund.payment_hash == payment_hash + payments = await get_payments(wallet_id=organiser.id, outgoing=True) + assert len(payments) == 1 + payment = payments[0] + assert payment.success and payment.payment_hash == payment_hash + assert payment.amount == -1000000 + assert payment.extra["ticket_id"] == ticket.id + organiser_after = await get_wallet(organiser.id) + buyer_after = await get_wallet(buyer.id) + assert organiser_after and organiser_after.balance == (0 if internal else 100) + assert buyer_after and buyer_after.balance == (2000 if internal else 1000) + if not internal: + assert payment_hash in payment_database.paid_invoices + + +@pytest.mark.asyncio +async def test_close_events_isolates_failures(event, monkeypatch): + event.canceled = True + following = event.copy(update={"id": "following"}) + monkeypatch.setattr( + services, "get_closed_events", AsyncMock(return_value=[event, following]) + ) + refund = AsyncMock(side_effect=[RuntimeError("database unavailable"), True]) + mark = AsyncMock() + warning = Mock() + monkeypatch.setattr(services, "refund_tickets", refund) + monkeypatch.setattr(services, "mark_event_closed", mark) + monkeypatch.setattr(services.logger, "warning", warning) + await services.close_events() + assert refund.await_count == 2 + mark.assert_awaited_once_with(following) + assert "RuntimeError: database unavailable" in warning.call_args.args[0] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("conditional", [False, True]) +async def test_closed_event_cleanup(database, event, conditional): + data = CreateEvent(**event.dict()) + data.closing_date = "2000-01-01" + data.extra.conditional = conditional + data.extra.min_tickets = 1 + closed = await crud.create_event(data) + closed.sold = 1 + await crud.update_event(closed) + for ticket_id in ("expired", "recent", "paid"): + ticket = await crud.create_ticket( + ticket_id, closed.wallet, closed.id, "Buyer", "buyer@example.com", {} + ) + ticket.paid = ticket_id == "paid" + if ticket_id == "expired": + ticket.time -= timedelta(days=2) + await crud.update_ticket(ticket) + + await services.close_events() + assert await crud.get_ticket("expired") is None + recent = await crud.get_ticket("recent") + assert recent and not recent.paid + assert await crud.get_ticket("paid") is not None + stored = await crud.get_event(closed.id) + assert stored and not stored.canceled and not stored.close_processed + + recent.time -= timedelta(days=2) + await crud.update_ticket(recent) + await services.close_events() + assert await crud.get_ticket("recent") is None + stored = await crud.get_event(closed.id) + assert stored and stored.close_processed + assert await crud.get_closed_events() == [] + + late = await crud.create_ticket( + "late", closed.wallet, closed.id, "Buyer", "buyer@example.com", {} + ) + await services.set_ticket_paid(late) + # A stale completion attempt must not hide a newly paid ticket. + await crud.mark_event_closed(stored) + reopened = await crud.get_event(closed.id) + assert reopened and not reopened.close_processed + assert len(await crud.get_closed_events()) == 1 + + +@pytest.mark.asyncio +async def test_closed_event_update_reopens_processing(database, app, event): + closed = await crud.create_event(CreateEvent(**event.dict())) + closed.close_processed = True + await crud.update_event(closed) + authorize(app, KeyType.admin) + async with AsyncClient( + transport=ASGITransport(app), base_url="http://test" + ) as client: + response = await client.put( + f"/events/api/v1/events/{closed.id}", + json=CreateEvent(**closed.dict()).dict(), + ) + assert response.status_code == 200 + stored = await crud.get_event(closed.id) + assert stored and not stored.close_processed + + +@pytest.mark.asyncio +async def test_closing_task_restarts(monkeypatch): + close = AsyncMock( + side_effect=[RuntimeError("temporary failure"), asyncio.CancelledError()] + ) + monkeypatch.setattr(tasks, "close_events", close) + monkeypatch.setattr(settings, "lnbits_running", True) + with pytest.raises(asyncio.CancelledError): + await task_manager._catch_everything_and_restart( + tasks.close_expired_events, restart_interval=0 + ) + assert close.await_count == 2 + + @pytest.mark.parametrize( "base_url", ["", "/relative", "ftp://example.com", "https://example.com/?x=1"] ) diff --git a/views_api.py b/views_api.py index 16d86ab..a3b7af9 100644 --- a/views_api.py +++ b/views_api.py @@ -48,7 +48,6 @@ get_ticket, get_tickets, get_tickets_paginated, - purge_unpaid_tickets, register_ticket, update_event, ) @@ -181,8 +180,6 @@ async def api_get_event(event_id: str) -> Event: raise HTTPException( status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist." ) - await purge_unpaid_tickets(event_id) - today = datetime.now(timezone.utc).date() active_waves = get_active_ticket_waves(event, today) is_sales_closed = today > datetime.strptime(event.closing_date, "%Y-%m-%d").date() @@ -192,10 +189,9 @@ async def api_get_event(event_id: str) -> Event: if event.amount_tickets < 1: raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is sold out.") if event.extra.conditional and not is_min_tickets_met and is_sales_closed: - event.canceled = True - await update_event(event) - await refund_tickets(event_id) + raise HTTPException(status_code=HTTPStatus.GONE, detail="Event canceled.") + if event.canceled: raise HTTPException(status_code=HTTPStatus.GONE, detail="Event canceled.") if not active_waves: @@ -238,6 +234,7 @@ async def api_event_create( "time": event.time, "sold": event.sold, "canceled": event.canceled, + "close_processed": False, } ) event = await update_event(event) @@ -265,6 +262,7 @@ async def api_event_cancel( if event.wallet != wallet.wallet.id: raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your event.") event.canceled = True + event.close_processed = False event = await update_event(event) await refund_tickets(event.id) From fef590b43c0bc8d7483b8bdd2c0cd3b36bf171c5 Mon Sep 17 00:00:00 2001 From: Tiago Vasconcelos Date: Mon, 19 Oct 2026 12:19:42 +0100 Subject: [PATCH 4/4] Align runtime requirements --- config.json | 2 +- pyproject.toml | 5 +- uv.lock | 312 +++++++++++++++++++++++++++++-------------------- 3 files changed, 192 insertions(+), 127 deletions(-) diff --git a/config.json b/config.json index f8acfca..389c95d 100644 --- a/config.json +++ b/config.json @@ -6,7 +6,7 @@ "short_description": "Sell and register event tickets", "description": "", "tile": "/events/static/image/events.png", - "min_lnbits_version": "1.5.5", + "min_lnbits_version": "1.6.0", "contributors": [ { "name": "talvasconcelos", diff --git a/pyproject.toml b/pyproject.toml index 0640a6c..10e7d3c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ requires-python = ">=3.10,<3.13" description = "LNbits, free and open-source Lightning wallet and accounts system." authors = [{ name = "Alan Bits", email = "alan@lnbits.com" }] urls = { Homepage = "https://lnbits.com", Repository = "https://github.com/lnbits/events" } -dependencies = [ "lnbits>1" ] +dependencies = [ "lnbits>=1.6.0" ] [dependency-groups] dev = [ @@ -20,6 +20,9 @@ dev = [ [tool.poetry] package-mode = false +# Keep UV configuration independent of a parent LNbits checkout. +[tool.uv] + [tool.mypy] exclude = "(nostr/*)" plugins = ["pydantic.mypy"] diff --git a/uv.lock b/uv.lock index a9cdbab..9a8991d 100644 --- a/uv.lock +++ b/uv.lock @@ -258,14 +258,14 @@ wheels = [ [[package]] name = "bip32" -version = "4.0" +version = "5.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "coincurve" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4a/37/b69968b1b6eaea1fedb8efdb1862d86e92b6f68e182f39c764f894984db5/bip32-4.0.tar.gz", hash = "sha256:8035588f252f569bb414bc60df151ae431fc1c6789a19488a32890532ef3a2fc", size = 21662, upload-time = "2024-09-07T12:40:26.388Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/ad/857ffe66a4bbe8f8f2e9c0e792e19944d7cd0909546e2ac47249857f82c3/bip32-5.0.0.tar.gz", hash = "sha256:4caa1f74eed9f2cd4624b55f34a4094f52542552fe3d0cc52e1179b8d6e9f21e", size = 21668, upload-time = "2025-11-13T18:36:25.181Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/00/bd/dcf1650776a241c10a2bc6826b6e23ff63bf55373bb053b716c69c463758/bip32-4.0-py3-none-any.whl", hash = "sha256:9728b38336129c00e1f870bbb3e328c9632d51c1bddeef4011fd3115cb3aeff9", size = 12898, upload-time = "2024-09-07T12:40:25.358Z" }, + { url = "https://files.pythonhosted.org/packages/d8/b7/f971ff16f791a65a19f75ca0fe341d1ed7e56c99deae1bc7652c687dfa38/bip32-5.0.0-py3-none-any.whl", hash = "sha256:b20872795ae2bb4e5fac351f53ccdf2b998f82e927413922a2c5473a004bd6d0", size = 13007, upload-time = "2025-11-13T18:36:23.62Z" }, ] [[package]] @@ -640,18 +640,6 @@ 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" @@ -685,16 +673,16 @@ wheels = [ [[package]] name = "fastapi" -version = "0.116.1" +version = "0.116.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "starlette" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/78/d7/6c8b3bfe33eeffa208183ec037fee0cce9f7f024089ab1c5d12ef04bd27c/fastapi-0.116.1.tar.gz", hash = "sha256:ed52cbf946abfd70c5a0dccb24673f0670deeb517a88b3544d03c2a6bf283143", size = 296485, upload-time = "2025-07-11T16:22:32.057Z" } +sdist = { url = "https://files.pythonhosted.org/packages/01/64/1296f46d6b9e3b23fb22e5d01af3f104ef411425531376212f1eefa2794d/fastapi-0.116.2.tar.gz", hash = "sha256:231a6af2fe21cfa2c32730170ad8514985fc250bec16c9b242d3b94c835ef529", size = 298595, upload-time = "2025-09-16T18:29:23.058Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/47/d63c60f59a59467fda0f93f46335c9d18526d7071f025cb5b89d5353ea42/fastapi-0.116.1-py3-none-any.whl", hash = "sha256:c46ac7c312df840f0c9e220f7964bada936781bc4e2e6eb71f1c4d7553786565", size = 95631, upload-time = "2025-07-11T16:22:30.485Z" }, + { url = "https://files.pythonhosted.org/packages/32/e4/c543271a8018874b7f682bf6156863c416e1334b8ed3e51a69495c5d4360/fastapi-0.116.2-py3-none-any.whl", hash = "sha256:c3a7a8fb830b05f7e087d920e0d786ca1fc9892eb4e9a84b227be4c1bc7569db", size = 95670, upload-time = "2025-09-16T18:29:21.329Z" }, ] [[package]] @@ -790,39 +778,36 @@ wheels = [ [[package]] name = "greenlet" -version = "3.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/86/94/a5935717b307d7c71fe877b52b884c6af707d2d2090db118a03fbd799369/greenlet-3.4.0.tar.gz", hash = "sha256:f50a96b64dafd6169e595a5c56c9146ef80333e67d4476a65a9c55f400fc22ff", size = 195913, upload-time = "2026-04-08T17:08:00.863Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/bc/e30e1e3d5e8860b0e0ce4d2b16b2681b77fd13542fc0d72f7e3c22d16eff/greenlet-3.4.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:d18eae9a7fb0f499efcd146b8c9750a2e1f6e0e93b5a382b3481875354a430e6", size = 284315, upload-time = "2026-04-08T17:02:52.322Z" }, - { url = "https://files.pythonhosted.org/packages/5b/cc/e023ae1967d2a26737387cac083e99e47f65f58868bd155c4c80c01ec4e0/greenlet-3.4.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:636d2f95c309e35f650e421c23297d5011716be15d966e6328b367c9fc513a82", size = 601916, upload-time = "2026-04-08T16:24:35.533Z" }, - { url = "https://files.pythonhosted.org/packages/67/32/5be1677954b6d8810b33abe94e3eb88726311c58fa777dc97e390f7caf5a/greenlet-3.4.0-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:234582c20af9742583c3b2ddfbdbb58a756cfff803763ffaae1ac7990a9fac31", size = 616399, upload-time = "2026-04-08T16:30:54.536Z" }, - { url = "https://files.pythonhosted.org/packages/82/0a/3a4af092b09ea02bcda30f33fd7db397619132fe52c6ece24b9363130d34/greenlet-3.4.0-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ac6a5f618be581e1e0713aecec8e54093c235e5fa17d6d8eb7ffc487e2300508", size = 621077, upload-time = "2026-04-08T16:40:34.946Z" }, - { url = "https://files.pythonhosted.org/packages/74/bf/2d58d5ea515704f83e34699128c9072a34bea27d2b6a556e102105fe62a5/greenlet-3.4.0-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:523677e69cd4711b5a014e37bc1fb3a29947c3e3a5bb6a527e1cc50312e5a398", size = 611978, upload-time = "2026-04-08T15:56:31.335Z" }, - { url = "https://files.pythonhosted.org/packages/8c/39/3786520a7d5e33ee87b3da2531f589a3882abf686a42a3773183a41ef010/greenlet-3.4.0-cp310-cp310-manylinux_2_39_riscv64.whl", hash = "sha256:d336d46878e486de7d9458653c722875547ac8d36a1cff9ffaf4a74a3c1f62eb", size = 416893, upload-time = "2026-04-08T16:43:02.392Z" }, - { url = "https://files.pythonhosted.org/packages/bd/69/6525049b6c179d8a923256304d8387b8bdd4acab1acf0407852463c6d514/greenlet-3.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b45e45fe47a19051a396abb22e19e7836a59ee6c5a90f3be427343c37908d65b", size = 1571957, upload-time = "2026-04-08T16:26:17.041Z" }, - { url = "https://files.pythonhosted.org/packages/4e/6c/bbfb798b05fec736a0d24dc23e81b45bcee87f45a83cfb39db031853bddc/greenlet-3.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5434271357be07f3ad0936c312645853b7e689e679e29310e2de09a9ea6c3adf", size = 1637223, upload-time = "2026-04-08T15:57:27.556Z" }, - { url = "https://files.pythonhosted.org/packages/b7/7d/981fe0e7c07bd9d5e7eb18decb8590a11e3955878291f7a7de2e9c668eb7/greenlet-3.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:a19093fbad824ed7c0f355b5ff4214bffda5f1a7f35f29b31fcaa240cc0135ab", size = 237902, upload-time = "2026-04-08T17:03:14.16Z" }, - { url = "https://files.pythonhosted.org/packages/fb/c6/dba32cab7e3a625b011aa5647486e2d28423a48845a2998c126dd69c85e1/greenlet-3.4.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:805bebb4945094acbab757d34d6e1098be6de8966009ab9ca54f06ff492def58", size = 285504, upload-time = "2026-04-08T15:52:14.071Z" }, - { url = "https://files.pythonhosted.org/packages/54/f4/7cb5c2b1feb9a1f50e038be79980dfa969aa91979e5e3a18fdbcfad2c517/greenlet-3.4.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:439fc2f12b9b512d9dfa681c5afe5f6b3232c708d13e6f02c845e0d9f4c2d8c6", size = 605476, upload-time = "2026-04-08T16:24:37.064Z" }, - { url = "https://files.pythonhosted.org/packages/d6/af/b66ab0b2f9a4c5a867c136bf66d9599f34f21a1bcca26a2884a29c450bd9/greenlet-3.4.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a70ed1cb0295bee1df57b63bf7f46b4e56a5c93709eea769c1fec1bb23a95875", size = 618336, upload-time = "2026-04-08T16:30:56.59Z" }, - { url = "https://files.pythonhosted.org/packages/6d/31/56c43d2b5de476f77d36ceeec436328533bff960a4cba9a07616e93063ab/greenlet-3.4.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8c5696c42e6bb5cfb7c6ff4453789081c66b9b91f061e5e9367fa15792644e76", size = 625045, upload-time = "2026-04-08T16:40:37.111Z" }, - { url = "https://files.pythonhosted.org/packages/e5/5c/8c5633ece6ba611d64bf2770219a98dd439921d6424e4e8cf16b0ac74ea5/greenlet-3.4.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c660bce1940a1acae5f51f0a064f1bc785d07ea16efcb4bc708090afc4d69e83", size = 613515, upload-time = "2026-04-08T15:56:32.478Z" }, - { url = "https://files.pythonhosted.org/packages/80/ca/704d4e2c90acb8bdf7ae593f5cbc95f58e82de95cc540fb75631c1054533/greenlet-3.4.0-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:89995ce5ddcd2896d89615116dd39b9703bfa0c07b583b85b89bf1b5d6eddf81", size = 419745, upload-time = "2026-04-08T16:43:04.022Z" }, - { url = "https://files.pythonhosted.org/packages/a9/df/950d15bca0d90a0e7395eb777903060504cdb509b7b705631e8fb69ff415/greenlet-3.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ee407d4d1ca9dc632265aee1c8732c4a2d60adff848057cdebfe5fe94eb2c8a2", size = 1574623, upload-time = "2026-04-08T16:26:18.596Z" }, - { url = "https://files.pythonhosted.org/packages/1a/e7/0839afab829fcb7333c9ff6d80c040949510055d2d4d63251f0d1c7c804e/greenlet-3.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:956215d5e355fffa7c021d168728321fd4d31fd730ac609b1653b450f6a4bc71", size = 1639579, upload-time = "2026-04-08T15:57:29.231Z" }, - { url = "https://files.pythonhosted.org/packages/d9/2b/b4482401e9bcaf9f5c97f67ead38db89c19520ff6d0d6699979c6efcc200/greenlet-3.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:5cb614ace7c27571270354e9c9f696554d073f8aa9319079dcba466bbdead711", size = 238233, upload-time = "2026-04-08T17:02:54.286Z" }, - { url = "https://files.pythonhosted.org/packages/0c/4d/d8123a4e0bcd583d5cfc8ddae0bbe29c67aab96711be331a7cc935a35966/greenlet-3.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:04403ac74fe295a361f650818de93be11b5038a78f49ccfb64d3b1be8fbf1267", size = 235045, upload-time = "2026-04-08T17:04:05.072Z" }, - { url = "https://files.pythonhosted.org/packages/65/8b/3669ad3b3f247a791b2b4aceb3aa5a31f5f6817bf547e4e1ff712338145a/greenlet-3.4.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:1a54a921561dd9518d31d2d3db4d7f80e589083063ab4d3e2e950756ef809e1a", size = 286902, upload-time = "2026-04-08T15:52:12.138Z" }, - { url = "https://files.pythonhosted.org/packages/38/3e/3c0e19b82900873e2d8469b590a6c4b3dfd2b316d0591f1c26b38a4879a5/greenlet-3.4.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16dec271460a9a2b154e3b1c2fa1050ce6280878430320e85e08c166772e3f97", size = 606099, upload-time = "2026-04-08T16:24:38.408Z" }, - { url = "https://files.pythonhosted.org/packages/b5/33/99fef65e7754fc76a4ed14794074c38c9ed3394a5bd129d7f61b705f3168/greenlet-3.4.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:90036ce224ed6fe75508c1907a77e4540176dcf0744473627785dd519c6f9996", size = 618837, upload-time = "2026-04-08T16:30:58.298Z" }, - { url = "https://files.pythonhosted.org/packages/44/57/eae2cac10421feae6c0987e3dc106c6d86262b1cb379e171b017aba893a6/greenlet-3.4.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6f0def07ec9a71d72315cf26c061aceee53b306c36ed38c35caba952ea1b319d", size = 624901, upload-time = "2026-04-08T16:40:38.981Z" }, - { url = "https://files.pythonhosted.org/packages/36/f7/229f3aed6948faa20e0616a0b8568da22e365ede6a54d7d369058b128afd/greenlet-3.4.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a1c4f6b453006efb8310affb2d132832e9bbb4fc01ce6df6b70d810d38f1f6dc", size = 615062, upload-time = "2026-04-08T15:56:33.766Z" }, - { url = "https://files.pythonhosted.org/packages/6a/8a/0e73c9b94f31d1cc257fe79a0eff621674141cdae7d6d00f40de378a1e42/greenlet-3.4.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:0e1254cf0cbaa17b04320c3a78575f29f3c161ef38f59c977108f19ffddaf077", size = 423927, upload-time = "2026-04-08T16:43:05.293Z" }, - { url = "https://files.pythonhosted.org/packages/08/97/d988180011aa40135c46cd0d0cf01dd97f7162bae14139b4a3ef54889ba5/greenlet-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9b2d9a138ffa0e306d0e2b72976d2fb10b97e690d40ab36a472acaab0838e2de", size = 1573511, upload-time = "2026-04-08T16:26:20.058Z" }, - { url = "https://files.pythonhosted.org/packages/d4/0f/a5a26fe152fb3d12e6a474181f6e9848283504d0afd095f353d85726374b/greenlet-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8424683caf46eb0eb6f626cb95e008e8cc30d0cb675bdfa48200925c79b38a08", size = 1640396, upload-time = "2026-04-08T15:57:30.88Z" }, - { url = "https://files.pythonhosted.org/packages/42/cf/bb2c32d9a100e36ee9f6e38fad6b1e082b8184010cb06259b49e1266ca01/greenlet-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:a0a53fb071531d003b075c444014ff8f8b1a9898d36bb88abd9ac7b3524648a2", size = 238892, upload-time = "2026-04-08T17:03:10.094Z" }, - { url = "https://files.pythonhosted.org/packages/b7/47/6c41314bac56e71436ce551c7fbe3cc830ed857e6aa9708dbb9c65142eb6/greenlet-3.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:f38b81880ba28f232f1f675893a39cf7b6db25b31cc0a09bb50787ecf957e85e", size = 235599, upload-time = "2026-04-08T15:52:54.3Z" }, +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/51/1664f6b78fc6ebbd98019a1fd730e83fa78f2db7058f72b1463d3612b8db/greenlet-3.3.2.tar.gz", hash = "sha256:2eaf067fc6d886931c7962e8c6bede15d2f01965560f3359b27c80bde2d151f2", size = 188267, upload-time = "2026-02-20T20:54:15.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/3f/9859f655d11901e7b2996c6e3d33e0caa9a1d4572c3bc61ed0faa64b2f4c/greenlet-3.3.2-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:9bc885b89709d901859cf95179ec9f6bb67a3d2bb1f0e88456461bd4b7f8fd0d", size = 277747, upload-time = "2026-02-20T20:16:21.325Z" }, + { url = "https://files.pythonhosted.org/packages/fb/07/cb284a8b5c6498dbd7cba35d31380bb123d7dceaa7907f606c8ff5993cbf/greenlet-3.3.2-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b568183cf65b94919be4438dc28416b234b678c608cafac8874dfeeb2a9bbe13", size = 579202, upload-time = "2026-02-20T20:47:28.955Z" }, + { url = "https://files.pythonhosted.org/packages/ed/45/67922992b3a152f726163b19f890a85129a992f39607a2a53155de3448b8/greenlet-3.3.2-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:527fec58dc9f90efd594b9b700662ed3fb2493c2122067ac9c740d98080a620e", size = 590620, upload-time = "2026-02-20T20:55:55.581Z" }, + { url = "https://files.pythonhosted.org/packages/03/5f/6e2a7d80c353587751ef3d44bb947f0565ec008a2e0927821c007e96d3a7/greenlet-3.3.2-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:508c7f01f1791fbc8e011bd508f6794cb95397fdb198a46cb6635eb5b78d85a7", size = 602132, upload-time = "2026-02-20T21:02:43.261Z" }, + { url = "https://files.pythonhosted.org/packages/ad/55/9f1ebb5a825215fadcc0f7d5073f6e79e3007e3282b14b22d6aba7ca6cb8/greenlet-3.3.2-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ad0c8917dd42a819fe77e6bdfcb84e3379c0de956469301d9fd36427a1ca501f", size = 591729, upload-time = "2026-02-20T20:20:58.395Z" }, + { url = "https://files.pythonhosted.org/packages/24/b4/21f5455773d37f94b866eb3cf5caed88d6cea6dd2c6e1f9c34f463cba3ec/greenlet-3.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:97245cc10e5515dbc8c3104b2928f7f02b6813002770cfaffaf9a6e0fc2b94ef", size = 1551946, upload-time = "2026-02-20T20:49:31.102Z" }, + { url = "https://files.pythonhosted.org/packages/00/68/91f061a926abead128fe1a87f0b453ccf07368666bd59ffa46016627a930/greenlet-3.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8c1fdd7d1b309ff0da81d60a9688a8bd044ac4e18b250320a96fc68d31c209ca", size = 1618494, upload-time = "2026-02-20T20:21:06.541Z" }, + { url = "https://files.pythonhosted.org/packages/ac/78/f93e840cbaef8becaf6adafbaf1319682a6c2d8c1c20224267a5c6c8c891/greenlet-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:5d0e35379f93a6d0222de929a25ab47b5eb35b5ef4721c2b9cbcc4036129ff1f", size = 230092, upload-time = "2026-02-20T20:17:09.379Z" }, + { url = "https://files.pythonhosted.org/packages/f3/47/16400cb42d18d7a6bb46f0626852c1718612e35dcb0dffa16bbaffdf5dd2/greenlet-3.3.2-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:c56692189a7d1c7606cb794be0a8381470d95c57ce5be03fb3d0ef57c7853b86", size = 278890, upload-time = "2026-02-20T20:19:39.263Z" }, + { url = "https://files.pythonhosted.org/packages/a3/90/42762b77a5b6aa96cd8c0e80612663d39211e8ae8a6cd47c7f1249a66262/greenlet-3.3.2-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ebd458fa8285960f382841da585e02201b53a5ec2bac6b156fc623b5ce4499f", size = 581120, upload-time = "2026-02-20T20:47:30.161Z" }, + { url = "https://files.pythonhosted.org/packages/bf/6f/f3d64f4fa0a9c7b5c5b3c810ff1df614540d5aa7d519261b53fba55d4df9/greenlet-3.3.2-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a443358b33c4ec7b05b79a7c8b466f5d275025e750298be7340f8fc63dff2a55", size = 594363, upload-time = "2026-02-20T20:55:56.965Z" }, + { url = "https://files.pythonhosted.org/packages/9c/8b/1430a04657735a3f23116c2e0d5eb10220928846e4537a938a41b350bed6/greenlet-3.3.2-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4375a58e49522698d3e70cc0b801c19433021b5c37686f7ce9c65b0d5c8677d2", size = 605046, upload-time = "2026-02-20T21:02:45.234Z" }, + { url = "https://files.pythonhosted.org/packages/72/83/3e06a52aca8128bdd4dcd67e932b809e76a96ab8c232a8b025b2850264c5/greenlet-3.3.2-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e2cd90d413acbf5e77ae41e5d3c9b3ac1d011a756d7284d7f3f2b806bbd6358", size = 594156, upload-time = "2026-02-20T20:20:59.955Z" }, + { url = "https://files.pythonhosted.org/packages/70/79/0de5e62b873e08fe3cef7dbe84e5c4bc0e8ed0c7ff131bccb8405cd107c8/greenlet-3.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:442b6057453c8cb29b4fb36a2ac689382fc71112273726e2423f7f17dc73bf99", size = 1554649, upload-time = "2026-02-20T20:49:32.293Z" }, + { url = "https://files.pythonhosted.org/packages/5a/00/32d30dee8389dc36d42170a9c66217757289e2afb0de59a3565260f38373/greenlet-3.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:45abe8eb6339518180d5a7fa47fa01945414d7cca5ecb745346fc6a87d2750be", size = 1619472, upload-time = "2026-02-20T20:21:07.966Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3a/efb2cf697fbccdf75b24e2c18025e7dfa54c4f31fab75c51d0fe79942cef/greenlet-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e692b2dae4cc7077cbb11b47d258533b48c8fde69a33d0d8a82e2fe8d8531d5", size = 230389, upload-time = "2026-02-20T20:17:18.772Z" }, + { url = "https://files.pythonhosted.org/packages/e1/a1/65bbc059a43a7e2143ec4fc1f9e3f673e04f9c7b371a494a101422ac4fd5/greenlet-3.3.2-cp311-cp311-win_arm64.whl", hash = "sha256:02b0a8682aecd4d3c6c18edf52bc8e51eacdd75c8eac52a790a210b06aa295fd", size = 229645, upload-time = "2026-02-20T20:18:18.695Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ab/1608e5a7578e62113506740b88066bf09888322a311cff602105e619bd87/greenlet-3.3.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:ac8d61d4343b799d1e526db579833d72f23759c71e07181c2d2944e429eb09cd", size = 280358, upload-time = "2026-02-20T20:17:43.971Z" }, + { url = "https://files.pythonhosted.org/packages/a5/23/0eae412a4ade4e6623ff7626e38998cb9b11e9ff1ebacaa021e4e108ec15/greenlet-3.3.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ceec72030dae6ac0c8ed7591b96b70410a8be370b6a477b1dbc072856ad02bd", size = 601217, upload-time = "2026-02-20T20:47:31.462Z" }, + { url = "https://files.pythonhosted.org/packages/f8/16/5b1678a9c07098ecb9ab2dd159fafaf12e963293e61ee8d10ecb55273e5e/greenlet-3.3.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2a5be83a45ce6188c045bcc44b0ee037d6a518978de9a5d97438548b953a1ac", size = 611792, upload-time = "2026-02-20T20:55:58.423Z" }, + { url = "https://files.pythonhosted.org/packages/5c/c5/cc09412a29e43406eba18d61c70baa936e299bc27e074e2be3806ed29098/greenlet-3.3.2-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae9e21c84035c490506c17002f5c8ab25f980205c3e61ddb3a2a2a2e6c411fcb", size = 626250, upload-time = "2026-02-20T21:02:46.596Z" }, + { url = "https://files.pythonhosted.org/packages/50/1f/5155f55bd71cabd03765a4aac9ac446be129895271f73872c36ebd4b04b6/greenlet-3.3.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e99d1749147ac21dde49b99c9abffcbc1e2d55c67501465ef0930d6e78e070", size = 613875, upload-time = "2026-02-20T20:21:01.102Z" }, + { url = "https://files.pythonhosted.org/packages/fc/dd/845f249c3fcd69e32df80cdab059b4be8b766ef5830a3d0aa9d6cad55beb/greenlet-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c956a19350e2c37f2c48b336a3afb4bff120b36076d9d7fb68cb44e05d95b79", size = 1571467, upload-time = "2026-02-20T20:49:33.495Z" }, + { url = "https://files.pythonhosted.org/packages/2a/50/2649fe21fcc2b56659a452868e695634722a6655ba245d9f77f5656010bf/greenlet-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c6f8ba97d17a1e7d664151284cb3315fc5f8353e75221ed4324f84eb162b395", size = 1640001, upload-time = "2026-02-20T20:21:09.154Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/cc802e067d02af8b60b6771cea7d57e21ef5e6659912814babb42b864713/greenlet-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:34308836d8370bddadb41f5a7ce96879b72e2fdfb4e87729330c6ab52376409f", size = 231081, upload-time = "2026-02-20T20:17:28.121Z" }, + { url = "https://files.pythonhosted.org/packages/58/2e/fe7f36ff1982d6b10a60d5e0740c759259a7d6d2e1dc41da6d96de32fff6/greenlet-3.3.2-cp312-cp312-win_arm64.whl", hash = "sha256:d3a62fa76a32b462a97198e4c9e99afb9ab375115e74e9a83ce180e7a496f643", size = 230331, upload-time = "2026-02-20T20:17:23.34Z" }, ] [[package]] @@ -913,6 +898,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/56/95/9377bcb415797e44274b51d46e3249eba641711cf3348050f76ee7b15ffc/httpx-0.27.2-py3-none-any.whl", hash = "sha256:7bb2708e112d8fdd7829cd4243970f0c223274051cb35ee80c03301ee29a3df0", size = 76395, upload-time = "2024-08-27T12:53:59.653Z" }, ] +[package.optional-dependencies] +socks = [ + { name = "socksio" }, +] + [[package]] name = "identify" version = "2.6.18" @@ -1035,7 +1025,7 @@ wheels = [ [[package]] name = "lnbits" -version = "1.5.3" +version = "1.6.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiosqlite" }, @@ -1062,6 +1052,7 @@ dependencies = [ { name = "protobuf" }, { name = "pycryptodomex" }, { name = "pydantic" }, + { name = "pyinstrument" }, { name = "pyjwt" }, { name = "pyln-client" }, { name = "pynostr" }, @@ -1070,20 +1061,23 @@ dependencies = [ { name = "python-dotenv" }, { name = "python-multipart" }, { name = "pywebpush" }, + { name = "random-username" }, { name = "shortuuid" }, { name = "slowapi" }, { name = "sqlalchemy" }, { name = "sse-starlette" }, { name = "starlette" }, { name = "typing-extensions" }, + { name = "urllib3" }, { name = "uvicorn" }, { name = "uvloop" }, + { name = "wasmtime" }, { name = "websocket-client" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e8/53/10bb8d783ea25dd64c496cfbac0220aad19bb1273749b178d6b2cea9dfb3/lnbits-1.5.3.tar.gz", hash = "sha256:cb4b55740c3cddd2cf4ef5971d0b60f3a0c3f11696e9326d27c17f4e6f46637d", size = 3870309, upload-time = "2026-03-25T11:11:30.237Z" } +sdist = { url = "https://files.pythonhosted.org/packages/95/82/46db10da416197b239a29c518eef4dfeb99bbbd5a340009e1a9c9b9e0cf6/lnbits-1.6.2.tar.gz", hash = "sha256:628c66c81a91cdca8831d1c3bd279216b8396288dc290094cfd106c656e21c81", size = 5398909, upload-time = "2026-09-18T13:43:42.409Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c0/62/743b939ec60545e395753a2385117c77d86c9f4a71912cd2bda54af1e777/lnbits-1.5.3-py3-none-any.whl", hash = "sha256:c8762f0819c6e97098b71a5a0865dde8b54e76a009db175f37b55b81551287b8", size = 3526810, upload-time = "2026-03-25T11:11:28.616Z" }, + { url = "https://files.pythonhosted.org/packages/d0/5f/710d94638159acbdb911c3cd51ca47baa3e56137d0e89b49de4202b2b1d6/lnbits-1.6.2-py3-none-any.whl", hash = "sha256:25649a8b40f58e30e19aa9bb3c391a92b26e47cf9f028818fcab10b38a849339", size = 3873292, upload-time = "2026-09-18T13:43:40.725Z" }, ] [[package]] @@ -1105,7 +1099,7 @@ dev = [ ] [package.metadata] -requires-dist = [{ name = "lnbits", specifier = ">1" }] +requires-dist = [{ name = "lnbits", specifier = ">=1.6.0" }] [package.metadata.requires-dev] dev = [ @@ -1119,20 +1113,20 @@ dev = [ [[package]] name = "lnurl" -version = "0.8.3" +version = "0.10.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "bech32" }, { name = "bip32" }, { name = "bolt11" }, - { name = "ecdsa" }, - { name = "httpx" }, + { name = "coincurve" }, + { name = "httpx", extra = ["socks"] }, { name = "pycryptodomex" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9b/dc/29cce6bb8688622c7f5f6db4355c54b5c87bb05be58e49374fd2c6f4b0f2/lnurl-0.8.3.tar.gz", hash = "sha256:8ca73af84fb9ee36a184d731d165f289ba7bc6260d4dadb2b6cf24f381c3afba", size = 17171, upload-time = "2025-09-08T13:30:56.636Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/5d/bf998e137d2c5999039a605823db67602a5ccd187b45df16909aa9e459ac/lnurl-0.10.4.tar.gz", hash = "sha256:c43a6cf45b6f20da67088193da5cd67276038f81188c287a8b2aecc323959022", size = 70070, upload-time = "2026-08-10T05:32:29.499Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/99/e400734afb7469a0cfa661259de2bb66417d6c3f14361444a010e9d186ee/lnurl-0.8.3-py3-none-any.whl", hash = "sha256:670cdeaef2c55de986dad89126ab58275d5199ba6554a93d9965d1e162080c2a", size = 17459, upload-time = "2025-09-08T13:30:55.691Z" }, + { url = "https://files.pythonhosted.org/packages/35/9d/d64637d347e69296ba83438c4c3144e38c881a2e6077978410894c6ad4ed/lnurl-0.10.4-py3-none-any.whl", hash = "sha256:1173de3fa47550ff9a9484f15f4b876c93220042423aef5205c6ae749f065f70", size = 18159, upload-time = "2026-08-10T05:32:28.59Z" }, ] [[package]] @@ -1380,50 +1374,42 @@ wheels = [ [[package]] name = "pillow" -version = "12.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/aa/d0b28e1c811cd4d5f5c2bfe2e022292bd255ae5744a3b9ac7d6c8f72dd75/pillow-12.2.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:a4e8f36e677d3336f35089648c8955c51c6d386a13cf6ee9c189c5f5bd713a9f", size = 5354355, upload-time = "2026-04-01T14:42:15.402Z" }, - { url = "https://files.pythonhosted.org/packages/27/8e/1d5b39b8ae2bd7650d0c7b6abb9602d16043ead9ebbfef4bc4047454da2a/pillow-12.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e589959f10d9824d39b350472b92f0ce3b443c0a3442ebf41c40cb8361c5b97", size = 4695871, upload-time = "2026-04-01T14:42:18.234Z" }, - { url = "https://files.pythonhosted.org/packages/f0/c5/dcb7a6ca6b7d3be41a76958e90018d56c8462166b3ef223150360850c8da/pillow-12.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a52edc8bfff4429aaabdf4d9ee0daadbbf8562364f940937b941f87a4290f5ff", size = 6269734, upload-time = "2026-04-01T14:42:20.608Z" }, - { url = "https://files.pythonhosted.org/packages/ea/f1/aa1bb13b2f4eba914e9637893c73f2af8e48d7d4023b9d3750d4c5eb2d0c/pillow-12.2.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:975385f4776fafde056abb318f612ef6285b10a1f12b8570f3647ad0d74b48ec", size = 8076080, upload-time = "2026-04-01T14:42:23.095Z" }, - { url = "https://files.pythonhosted.org/packages/a1/2a/8c79d6a53169937784604a8ae8d77e45888c41537f7f6f65ed1f407fe66d/pillow-12.2.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd9c0c7a0c681a347b3194c500cb1e6ca9cab053ea4d82a5cf45b6b754560136", size = 6382236, upload-time = "2026-04-01T14:42:25.82Z" }, - { url = "https://files.pythonhosted.org/packages/b5/42/bbcb6051030e1e421d103ce7a8ecadf837aa2f39b8f82ef1a8d37c3d4ebc/pillow-12.2.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88d387ff40b3ff7c274947ed3125dedf5262ec6919d83946753b5f3d7c67ea4c", size = 7070220, upload-time = "2026-04-01T14:42:28.68Z" }, - { url = "https://files.pythonhosted.org/packages/3f/e1/c2a7d6dd8cfa6b231227da096fd2d58754bab3603b9d73bf609d3c18b64f/pillow-12.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:51c4167c34b0d8ba05b547a3bb23578d0ba17b80a5593f93bd8ecb123dd336a3", size = 6493124, upload-time = "2026-04-01T14:42:31.579Z" }, - { url = "https://files.pythonhosted.org/packages/5f/41/7c8617da5d32e1d2f026e509484fdb6f3ad7efaef1749a0c1928adbb099e/pillow-12.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:34c0d99ecccea270c04882cb3b86e7b57296079c9a4aff88cb3b33563d95afaa", size = 7194324, upload-time = "2026-04-01T14:42:34.615Z" }, - { url = "https://files.pythonhosted.org/packages/2d/de/a777627e19fd6d62f84070ee1521adde5eeda4855b5cf60fe0b149118bca/pillow-12.2.0-cp310-cp310-win32.whl", hash = "sha256:b85f66ae9eb53e860a873b858b789217ba505e5e405a24b85c0464822fe88032", size = 6376363, upload-time = "2026-04-01T14:42:37.19Z" }, - { url = "https://files.pythonhosted.org/packages/e7/34/fc4cb5204896465842767b96d250c08410f01f2f28afc43b257de842eed5/pillow-12.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:673aa32138f3e7531ccdbca7b3901dba9b70940a19ccecc6a37c77d5fdeb05b5", size = 7083523, upload-time = "2026-04-01T14:42:39.62Z" }, - { url = "https://files.pythonhosted.org/packages/2d/a0/32852d36bc7709f14dc3f64f929a275e958ad8c19a6deba9610d458e28b3/pillow-12.2.0-cp310-cp310-win_arm64.whl", hash = "sha256:3e080565d8d7c671db5802eedfb438e5565ffa40115216eabb8cd52d0ecce024", size = 2463318, upload-time = "2026-04-01T14:42:42.063Z" }, - { url = "https://files.pythonhosted.org/packages/68/e1/748f5663efe6edcfc4e74b2b93edfb9b8b99b67f21a854c3ae416500a2d9/pillow-12.2.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:8be29e59487a79f173507c30ddf57e733a357f67881430449bb32614075a40ab", size = 5354347, upload-time = "2026-04-01T14:42:44.255Z" }, - { url = "https://files.pythonhosted.org/packages/47/a1/d5ff69e747374c33a3b53b9f98cca7889fce1fd03d79cdc4e1bccc6c5a87/pillow-12.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71cde9a1e1551df7d34a25462fc60325e8a11a82cc2e2f54578e5e9a1e153d65", size = 4695873, upload-time = "2026-04-01T14:42:46.452Z" }, - { url = "https://files.pythonhosted.org/packages/df/21/e3fbdf54408a973c7f7f89a23b2cb97a7ef30c61ab4142af31eee6aebc88/pillow-12.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7", size = 6280168, upload-time = "2026-04-01T14:42:49.228Z" }, - { url = "https://files.pythonhosted.org/packages/d3/f1/00b7278c7dd52b17ad4329153748f87b6756ec195ff786c2bdf12518337d/pillow-12.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8bd7903a5f2a4545f6fd5935c90058b89d30045568985a71c79f5fd6edf9b91e", size = 8088188, upload-time = "2026-04-01T14:42:51.735Z" }, - { url = "https://files.pythonhosted.org/packages/ad/cf/220a5994ef1b10e70e85748b75649d77d506499352be135a4989c957b701/pillow-12.2.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3997232e10d2920a68d25191392e3a4487d8183039e1c74c2297f00ed1c50705", size = 6394401, upload-time = "2026-04-01T14:42:54.343Z" }, - { url = "https://files.pythonhosted.org/packages/e9/bd/e51a61b1054f09437acfbc2ff9106c30d1eb76bc1453d428399946781253/pillow-12.2.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e74473c875d78b8e9d5da2a70f7099549f9eb37ded4e2f6a463e60125bccd176", size = 7079655, upload-time = "2026-04-01T14:42:56.954Z" }, - { url = "https://files.pythonhosted.org/packages/6b/3d/45132c57d5fb4b5744567c3817026480ac7fc3ce5d4c47902bc0e7f6f853/pillow-12.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:56a3f9c60a13133a98ecff6197af34d7824de9b7b38c3654861a725c970c197b", size = 6503105, upload-time = "2026-04-01T14:42:59.847Z" }, - { url = "https://files.pythonhosted.org/packages/7d/2e/9df2fc1e82097b1df3dce58dc43286aa01068e918c07574711fcc53e6fb4/pillow-12.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:90e6f81de50ad6b534cab6e5aef77ff6e37722b2f5d908686f4a5c9eba17a909", size = 7203402, upload-time = "2026-04-01T14:43:02.664Z" }, - { url = "https://files.pythonhosted.org/packages/bd/2e/2941e42858ebb67e50ae741473de81c2984e6eff7b397017623c676e2e8d/pillow-12.2.0-cp311-cp311-win32.whl", hash = "sha256:8c984051042858021a54926eb597d6ee3012393ce9c181814115df4c60b9a808", size = 6378149, upload-time = "2026-04-01T14:43:05.274Z" }, - { url = "https://files.pythonhosted.org/packages/69/42/836b6f3cd7f3e5fa10a1f1a5420447c17966044c8fbf589cc0452d5502db/pillow-12.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e6b2a0c538fc200b38ff9eb6628228b77908c319a005815f2dde585a0664b60", size = 7082626, upload-time = "2026-04-01T14:43:08.557Z" }, - { url = "https://files.pythonhosted.org/packages/c2/88/549194b5d6f1f494b485e493edc6693c0a16f4ada488e5bd974ed1f42fad/pillow-12.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:9a8a34cc89c67a65ea7437ce257cea81a9dad65b29805f3ecee8c8fe8ff25ffe", size = 2463531, upload-time = "2026-04-01T14:43:10.743Z" }, - { url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" }, - { url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" }, - { url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" }, - { url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" }, - { url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" }, - { url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" }, - { url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" }, - { url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" }, - { url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" }, - { url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" }, - { url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" }, - { url = "https://files.pythonhosted.org/packages/4e/b7/2437044fb910f499610356d1352e3423753c98e34f915252aafecc64889f/pillow-12.2.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f", size = 5273969, upload-time = "2026-04-01T14:45:55.538Z" }, - { url = "https://files.pythonhosted.org/packages/f6/f4/8316e31de11b780f4ac08ef3654a75555e624a98db1056ecb2122d008d5a/pillow-12.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d", size = 4659674, upload-time = "2026-04-01T14:45:58.093Z" }, - { url = "https://files.pythonhosted.org/packages/d4/37/664fca7201f8bb2aa1d20e2c3d5564a62e6ae5111741966c8319ca802361/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f", size = 5288479, upload-time = "2026-04-01T14:46:01.141Z" }, - { url = "https://files.pythonhosted.org/packages/49/62/5b0ed78fce87346be7a5cfcfaaad91f6a1f98c26f86bdbafa2066c647ef6/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0c838a5125cee37e68edec915651521191cef1e6aa336b855f495766e77a366e", size = 7032230, upload-time = "2026-04-01T14:46:03.874Z" }, - { url = "https://files.pythonhosted.org/packages/c3/28/ec0fc38107fc32536908034e990c47914c57cd7c5a3ece4d8d8f7ffd7e27/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6c9fa44005fa37a91ebfc95d081e8079757d2e904b27103f4f5fa6f0bf78c0", size = 5355404, upload-time = "2026-04-01T14:46:06.33Z" }, - { url = "https://files.pythonhosted.org/packages/5e/8b/51b0eddcfa2180d60e41f06bd6d0a62202b20b59c68f5a132e615b75aecf/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25373b66e0dd5905ed63fa3cae13c82fbddf3079f2c8bf15c6fb6a35586324c1", size = 6002215, upload-time = "2026-04-01T14:46:08.83Z" }, - { url = "https://files.pythonhosted.org/packages/bc/60/5382c03e1970de634027cee8e1b7d39776b778b81812aaf45b694dfe9e28/pillow-12.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e", size = 7080946, upload-time = "2026-04-01T14:46:11.734Z" }, +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/25/c2/669d88644cddb1485bd9534e63e8cf476c8e51cb3c3a1297677023505c0e/pillow-12.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:6c0016e7b354317c4e9e525b937ac8596c38d2d232b419529b9cd7a1cd46e39a", size = 5392418, upload-time = "2026-07-01T11:53:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ba/3762f376a2948e3036488d773a146e0ae6ecc2ca03ac20e2615bd0b2ba02/pillow-12.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:bcc33feacfaefce60c12fd500a277533bdc02b10a19f7f6d348763d8140bbba7", size = 4785287, upload-time = "2026-07-01T11:53:29.761Z" }, + { url = "https://files.pythonhosted.org/packages/07/50/b5d688cc9c52d4482f3d5bcab6ce20bc2a74a85d2343841c907444a3be2c/pillow-12.3.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5594fc43d548a7ed94949d139aa1341b270f1863f11cfd37f5a6c8b778a6b67f", size = 6253754, upload-time = "2026-07-01T11:53:32.298Z" }, + { url = "https://files.pythonhosted.org/packages/4e/89/36f4cd76cf4baf05c50ababb976249153f18c959171c7f6ba09a6f217260/pillow-12.3.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0606c8bf2cdefea14a43530f7657cbbb7ecf1c4222512492ef4a4434a9501ec", size = 6925605, upload-time = "2026-07-01T11:53:34.487Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c0/4de58cf6633b9e3a6061ef4be6fb91fc3c90b812ece886f531e3c523d777/pillow-12.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:85f998ea1848bc6757289e739cfbdda3a04adfd58b02fc018ce54d754a5ce468", size = 6327788, upload-time = "2026-07-01T11:53:36.433Z" }, + { url = "https://files.pythonhosted.org/packages/87/3c/14d53682a19550dbbaf3b598f807d5457646c510805a44c7d7891cd1cd1a/pillow-12.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:25b9b82bb22e6e2b3cd07b39c68b7b862001226cb3dff7130d1cb914121b39ed", size = 7036288, upload-time = "2026-07-01T11:53:38.712Z" }, + { url = "https://files.pythonhosted.org/packages/38/1d/36279e3c77efe034e4cc2b0393ee74ffdb5a62391dacbf9b916154f5f0b8/pillow-12.3.0-cp310-cp310-win32.whl", hash = "sha256:37dc8f7bbb66efe481bb60defacef820c950c24713fb44962ed6aa2a50966de1", size = 6472396, upload-time = "2026-07-01T11:53:40.781Z" }, + { url = "https://files.pythonhosted.org/packages/48/7c/8fa0039574c476d7c6fa57dd7c32a130436877c6ec1e5ce1cc8ec44878c1/pillow-12.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:300557495eb45ebb8aec96c2da9c4be642fbf7cd937278b4013ba894ea8eb0eb", size = 7226887, upload-time = "2026-07-01T11:53:42.764Z" }, + { url = "https://files.pythonhosted.org/packages/fa/17/e324be141d173c1c919428066c3259f21c1b8982e564e01a4a81e96dbdcf/pillow-12.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:514435a37670e3e5e08f3945b68718b6ed329bb84367777e16f9f4dfe1e61a0f", size = 2568039, upload-time = "2026-07-01T11:53:45.372Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c8/0a78b0e02d7ac54bc03e5321c9220da52f0c2ea83b21f7c40e7f3169c502/pillow-12.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756", size = 5392415, upload-time = "2026-07-01T11:53:47.162Z" }, + { url = "https://files.pythonhosted.org/packages/b2/5b/a02d30018abd97ced9f5a6c63d28597694a00d066516b9c1c6de45859fc9/pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6", size = 4785266, upload-time = "2026-07-01T11:53:49.079Z" }, + { url = "https://files.pythonhosted.org/packages/c8/98/766667a4be768150a202836acd9fad19c06824ca86c4286d3cf6b274964e/pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd", size = 6263814, upload-time = "2026-07-01T11:53:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2d/ede717bc1144f63886c21fd349bb95860b0d1a21149ff16f2bb362b612b6/pillow-12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd", size = 6934408, upload-time = "2026-07-01T11:53:53.487Z" }, + { url = "https://files.pythonhosted.org/packages/a3/48/9c58b685e69d49c31af6c8eb9012055fab7e665785165c84796e2c73ce72/pillow-12.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c", size = 6337160, upload-time = "2026-07-01T11:53:55.457Z" }, + { url = "https://files.pythonhosted.org/packages/ff/fa/dc2a5c0ba6df93f67c31d34b808b7ce440b40cdbf96f0b81cde1d1e6fa93/pillow-12.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5", size = 7045172, upload-time = "2026-07-01T11:53:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/86/a5/444817a4d4c4c2417df00513086ca196f388d8f9ef40c2e4ccd1ad1af54b/pillow-12.3.0-cp311-cp311-win32.whl", hash = "sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b", size = 6472232, upload-time = "2026-07-01T11:53:59.767Z" }, + { url = "https://files.pythonhosted.org/packages/63/c6/4bad1b18d132a50b27e1365e1ab163616f7a5bb56d330f66f9d1d9d4f9d4/pillow-12.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a", size = 7233653, upload-time = "2026-07-01T11:54:02.066Z" }, + { url = "https://files.pythonhosted.org/packages/fd/16/00f91ab7760dc842f5aad55217e80fc4a7067a0604535249bc8a2d6d9870/pillow-12.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26", size = 2568195, upload-time = "2026-07-01T11:54:04.622Z" }, + { 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/75/18/2e8b40223153ccbc60df07f9e8928dc0c76202aa4e55ae9f53962b6510d6/pillow-12.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468", size = 5302510, upload-time = "2026-07-01T11:56:25.736Z" }, + { url = "https://files.pythonhosted.org/packages/46/3e/51fabf59d5ab801ceab709453d3ab6b180083496579549de4c45ced6528a/pillow-12.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94", size = 4736058, upload-time = "2026-07-01T11:56:28.041Z" }, + { url = "https://files.pythonhosted.org/packages/bf/20/22fe9384b7949e25fb1293bcfc84fb82590ff4ea6b37c95b24d26d793d86/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e", size = 5237776, upload-time = "2026-07-01T11:56:30.263Z" }, + { url = "https://files.pythonhosted.org/packages/08/14/f6ba68107680ffa74b39985f3f30884e41318fbc4250caa423c79b4788bb/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3", size = 5860358, upload-time = "2026-07-01T11:56:32.68Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload-time = "2026-07-01T11:56:35.046Z" }, ] [[package]] @@ -1525,17 +1511,17 @@ wheels = [ [[package]] name = "protobuf" -version = "6.33.2" +version = "6.33.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/34/44/e49ecff446afeec9d1a66d6bbf9adc21e3c7cea7803a920ca3773379d4f6/protobuf-6.33.2.tar.gz", hash = "sha256:56dc370c91fbb8ac85bc13582c9e373569668a290aa2e66a590c2a0d35ddb9e4", size = 444296, upload-time = "2025-12-06T00:17:53.311Z" } +sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531, upload-time = "2026-03-18T19:05:00.988Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bc/91/1e3a34881a88697a7354ffd177e8746e97a722e5e8db101544b47e84afb1/protobuf-6.33.2-cp310-abi3-win32.whl", hash = "sha256:87eb388bd2d0f78febd8f4c8779c79247b26a5befad525008e49a6955787ff3d", size = 425603, upload-time = "2025-12-06T00:17:41.114Z" }, - { url = "https://files.pythonhosted.org/packages/64/20/4d50191997e917ae13ad0a235c8b42d8c1ab9c3e6fd455ca16d416944355/protobuf-6.33.2-cp310-abi3-win_amd64.whl", hash = "sha256:fc2a0e8b05b180e5fc0dd1559fe8ebdae21a27e81ac77728fb6c42b12c7419b4", size = 436930, upload-time = "2025-12-06T00:17:43.278Z" }, - { url = "https://files.pythonhosted.org/packages/b2/ca/7e485da88ba45c920fb3f50ae78de29ab925d9e54ef0de678306abfbb497/protobuf-6.33.2-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:d9b19771ca75935b3a4422957bc518b0cecb978b31d1dd12037b088f6bcc0e43", size = 427621, upload-time = "2025-12-06T00:17:44.445Z" }, - { url = "https://files.pythonhosted.org/packages/7d/4f/f743761e41d3b2b2566748eb76bbff2b43e14d5fcab694f494a16458b05f/protobuf-6.33.2-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:b5d3b5625192214066d99b2b605f5783483575656784de223f00a8d00754fc0e", size = 324460, upload-time = "2025-12-06T00:17:45.678Z" }, - { url = "https://files.pythonhosted.org/packages/b1/fa/26468d00a92824020f6f2090d827078c09c9c587e34cbfd2d0c7911221f8/protobuf-6.33.2-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:8cd7640aee0b7828b6d03ae518b5b4806fdfc1afe8de82f79c3454f8aef29872", size = 339168, upload-time = "2025-12-06T00:17:46.813Z" }, - { url = "https://files.pythonhosted.org/packages/56/13/333b8f421738f149d4fe5e49553bc2a2ab75235486259f689b4b91f96cec/protobuf-6.33.2-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:1f8017c48c07ec5859106533b682260ba3d7c5567b1ca1f24297ce03384d1b4f", size = 323270, upload-time = "2025-12-06T00:17:48.253Z" }, - { url = "https://files.pythonhosted.org/packages/0e/15/4f02896cc3df04fc465010a4c6a0cd89810f54617a32a70ef531ed75d61c/protobuf-6.33.2-py3-none-any.whl", hash = "sha256:7636aad9bb01768870266de5dc009de2d1b936771b38a793f73cbbf279c91c5c", size = 170501, upload-time = "2025-12-06T00:17:52.211Z" }, + { url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739, upload-time = "2026-03-18T19:04:48.373Z" }, + { url = "https://files.pythonhosted.org/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", size = 437089, upload-time = "2026-03-18T19:04:50.381Z" }, + { url = "https://files.pythonhosted.org/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", size = 427737, upload-time = "2026-03-18T19:04:51.866Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", size = 324610, upload-time = "2026-03-18T19:04:53.096Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", size = 339381, upload-time = "2026-03-18T19:04:54.616Z" }, + { url = "https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", size = 323436, upload-time = "2026-03-18T19:04:55.768Z" }, + { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, ] [[package]] @@ -1624,13 +1610,52 @@ 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 = "pyinstrument" +version = "5.1.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a0/05/5b79b16712f9b7c497f2137868908e5d38646a8ef7871d6008801e6e18a3/pyinstrument-5.1.3.tar.gz", hash = "sha256:93dc5576fa90bb267c46d864712329e8e057f51a6b15d0b4f917558d82066ba7", size = 262250, upload-time = "2026-07-29T17:18:39.748Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/cd/ea6df41d0e69e726fc1873b44380796b753c3b337b823908314f2a907099/pyinstrument-5.1.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c8b8e003feab0658b6bb91eb61dd96034dc243a994cb61adadd02ce186c6158b", size = 126807, upload-time = "2026-07-29T17:17:16.554Z" }, + { url = "https://files.pythonhosted.org/packages/e6/cf/d69a6e34b8eaf04496c73cc2069ae255849ce4d3919173921da8826ab8d4/pyinstrument-5.1.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f3dfc649702c99256d44f38435986d36f8be6cd14b268c75eccb2e6ce2bd2942", size = 119955, upload-time = "2026-07-29T17:17:18.284Z" }, + { url = "https://files.pythonhosted.org/packages/4c/e0/ccb0595dc1f03c4099ced23a2509e24c472a9f4b1c993a569fb50b0d8741/pyinstrument-5.1.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7846c30455fc15e2910bdabc273c9a5685b2e5c37b58a960854f66940689de46", size = 144579, upload-time = "2026-07-29T17:17:19.654Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6e/6c5f6cab9209769eede74ce78812f9f015f6a110b780bd0486b962ec509b/pyinstrument-5.1.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c58bfda00a4247d53f1c733d5293aa1aefe75ad9ba0df439f736ee386cd234bd", size = 143287, upload-time = "2026-07-29T17:17:21.299Z" }, + { url = "https://files.pythonhosted.org/packages/4f/17/b0317f41e25265a510ca4affe87d440d174f09ff265a1be51c38f97b5268/pyinstrument-5.1.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:821318352dfdae169299d4849b8604c49c70ad67f5230d97454a91db4e98d207", size = 143517, upload-time = "2026-07-29T17:17:23.147Z" }, + { url = "https://files.pythonhosted.org/packages/b6/d1/210c1d33334a6dfd0f6406e151667bf5edd8adb077d041f429e9febc8adb/pyinstrument-5.1.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:6a70a333780cdcdc6a02c10c3ec46b4755575047d7039b990b1d7cf669cf3d2d", size = 143039, upload-time = "2026-07-29T17:17:24.413Z" }, + { url = "https://files.pythonhosted.org/packages/fe/b9/8475e6533b3dd862df3ad6b1d4535c69475ff7f789d4d872b3c9499b3c5b/pyinstrument-5.1.3-cp310-cp310-win32.whl", hash = "sha256:5b62ff755975c6a3a5752fd1d441e6633f4e01179470395afc1f1cb44630f02d", size = 120607, upload-time = "2026-07-29T17:17:25.766Z" }, + { url = "https://files.pythonhosted.org/packages/66/e1/ab44fb2b6c3ecfea902e25d9fada3df6bb801c874c4a400e754edf2c1094/pyinstrument-5.1.3-cp310-cp310-win_amd64.whl", hash = "sha256:49aa1434302880766c509a8b75d44277b9312de78d36a0a2a61f1103617a0f0f", size = 121501, upload-time = "2026-07-29T17:17:27.078Z" }, + { url = "https://files.pythonhosted.org/packages/f9/73/474b513a521b14b5fc58e7f191061bee78192deec4e22c8dc8d6ddeec628/pyinstrument-5.1.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:157aa322ceb07c2b990591c48b60a66482cad1026fdd53debd9f9ce7afb9b326", size = 126610, upload-time = "2026-07-29T17:17:28.755Z" }, + { url = "https://files.pythonhosted.org/packages/3e/75/a2ba3a91600191492391f0ba997ae781c0c8791f01fc31ab381cba03318d/pyinstrument-5.1.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cd1a74b9dec4fafc4cf4dd1df9cda56a83b7cb3e3826236044edaae2a2d6edbe", size = 119854, upload-time = "2026-07-29T17:17:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/69/c7/dbb65c0e0c6dc189471607e580af8c44daf007949f99a9563489aaa7363b/pyinstrument-5.1.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21b1486d8493b81fdef30e833ba4856785c34a79c9aea29c91bff5003a84e40a", size = 143448, upload-time = "2026-07-29T17:17:31.206Z" }, + { url = "https://files.pythonhosted.org/packages/e0/50/e77726eac04a5070ebb69ad9456c0a5649c1b3fa9870504f3a49fd3a975d/pyinstrument-5.1.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c4bedf32ff7fd56fbd5d5e9ccd771bb27884faab312a990685a2d5e97c83f882", size = 141909, upload-time = "2026-07-29T17:17:32.619Z" }, + { url = "https://files.pythonhosted.org/packages/d8/ba/7766a636c1afa7a844054a077f9dd05aa70c2bcaa2ca4573c079d1f7be56/pyinstrument-5.1.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:472a547412c78b7d783f28d7cdca7cdc870d172444a29078652a2e5bca406741", size = 142562, upload-time = "2026-07-29T17:17:34.118Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ea/edb64ef7b0d9de1fc2458b4f9c22fda82f33781f93510a3bc8cff591611c/pyinstrument-5.1.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7b31be199d1da29b19c522cafeef0e0778f2c8c4be349b56e17ff93b5ca8eff9", size = 141737, upload-time = "2026-07-29T17:17:35.742Z" }, + { url = "https://files.pythonhosted.org/packages/2c/d3/d7f48a894f1a2a147263b892ee019b0c5bda38105ded85799a3ae53ca248/pyinstrument-5.1.3-cp311-cp311-win32.whl", hash = "sha256:6a4d948fd53df2891986a6c539ad463db729c4528dea4c16a7f995fe719758a2", size = 120618, upload-time = "2026-07-29T17:17:37.152Z" }, + { url = "https://files.pythonhosted.org/packages/80/b9/cc9a9dc3e055840b477b1b147985f6ae251e5eebeaa257ff43ecd80c1c86/pyinstrument-5.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:fc46be132af558e9381383bacfe986da5abb9e1129151dc6ac760d8e4e420e0d", size = 121409, upload-time = "2026-07-29T17:17:38.443Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/cf24adef45bdfa9dc59371713f960c449663ae90cbe0435ce353b38e3c8d/pyinstrument-5.1.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eef82fd717e38c821b2276f50aa9812825036f03e7b345f2969dd264214cfc60", size = 126756, upload-time = "2026-07-29T17:17:39.758Z" }, + { url = "https://files.pythonhosted.org/packages/89/bd/ef19f60fb92c800d5d9c12f09d86e541fdec794d98840fb2996d462d4d1d/pyinstrument-5.1.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:58009e21257ed0e139a666dfc628a6fa6a734fca3ec7bde77d51d43fc4947d7b", size = 119832, upload-time = "2026-07-29T17:17:40.972Z" }, + { url = "https://files.pythonhosted.org/packages/48/5c/ed9d97b6c405580e18f304b613f482d1f5c7b52a18c3b4154ad0a1841e0c/pyinstrument-5.1.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d6cbef7ea81fa11bbca1b0bbf9d1d56bf2da96b3f675b593142c8772f7d0dc35", size = 145074, upload-time = "2026-07-29T17:17:42.305Z" }, + { url = "https://files.pythonhosted.org/packages/d7/6e/cd47fa4c2fef0d86a25684f0857df854155dfd2492bbbedd33b6c07f0578/pyinstrument-5.1.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4db9ebe8242038bf9f60c623bac0811611e54363a2fe33b79448b548b9108bef", size = 143859, upload-time = "2026-07-29T17:17:43.812Z" }, + { url = "https://files.pythonhosted.org/packages/67/72/e471ce7be3332143f4fbf9886c3ed0726792d2d533d4c130682f611bbe90/pyinstrument-5.1.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f16e1501e9d3a423b837aacc0b6ce9fa7c2fbf5e0e73a7afe9847912d805594c", size = 143948, upload-time = "2026-07-29T17:17:45.056Z" }, + { url = "https://files.pythonhosted.org/packages/fe/d6/1225f67d8da66c93ebdbf97081f9169b52d16c2e4453477f4f7e2de70879/pyinstrument-5.1.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c027d490a6caa2f18bf92ceecc46ab8580c8eee772af34b04c61c18fb4adf853", size = 143561, upload-time = "2026-07-29T17:17:46.329Z" }, + { url = "https://files.pythonhosted.org/packages/16/85/e6da5dbcb4890f40e06500f55344b3361a54fb6773fc9fc63f3ba30ee47f/pyinstrument-5.1.3-cp312-cp312-win32.whl", hash = "sha256:5a5c2d30f255f0a84f9b5cd53e17877e3e73b921d34b395f17a206f85fda2cfc", size = 120745, upload-time = "2026-07-29T17:17:47.623Z" }, + { url = "https://files.pythonhosted.org/packages/c3/fd/617fc91f97d617db558a0d863aaf9101f12203017ca2a07f11618a7094ef/pyinstrument-5.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:1ad617768b3c35acc4db89b5130fc0b98ce763f3a42dde255447bed3bd40d306", size = 121486, upload-time = "2026-07-29T17:17:48.881Z" }, + { url = "https://files.pythonhosted.org/packages/4d/7e/94412787ed5320450664baf66bb2f46a0f0fec21742ef9701c8399cbc026/pyinstrument-5.1.3-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:a8bae0a0bf1ec2e54bd7a3a456395e1a1e695c53e06252b8e6f43b2c5f344139", size = 120787, upload-time = "2026-07-29T17:18:34.006Z" }, + { url = "https://files.pythonhosted.org/packages/01/a5/43e397d6f1f2eecf8ac82e6c2ccb252493cfd413776bd094e4e770d4f762/pyinstrument-5.1.3-graalpy312-graalpy250_312_native-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8b8a126894ea5553a7a565f86e26ae3c56a7b0a7c73422fbd382de3a34a1480", size = 123272, upload-time = "2026-07-29T17:18:35.447Z" }, + { url = "https://files.pythonhosted.org/packages/2b/47/a51976758124654e18d1c11a2dcd6811a7a9c4e03f50d9ee8438e4fe6d20/pyinstrument-5.1.3-graalpy312-graalpy250_312_native-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e72d5db0bdc8488eba396a5447bdc7ecff067cbd4d7ca8f1d7b862dae0e9c2f6", size = 122216, upload-time = "2026-07-29T17:18:36.748Z" }, + { url = "https://files.pythonhosted.org/packages/50/b2/f4708a7e1f7ad1777ed8b559b3ff08f1ed52059205c704d6e12bb941caa1/pyinstrument-5.1.3-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:8f6d68350a2314222f85e32ccc519b69bcd41c82349e7b280ba5ebb473a5633a", size = 121850, upload-time = "2026-07-29T17:18:38.05Z" }, +] + [[package]] name = "pyjwt" -version = "2.10.1" +version = "2.12.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" }, + { url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" }, ] [[package]] @@ -1769,11 +1794,11 @@ wheels = [ [[package]] name = "python-multipart" -version = "0.0.21" +version = "0.0.32" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/78/96/804520d0850c7db98e5ccb70282e29208723f0964e88ffd9d0da2f52ea09/python_multipart-0.0.21.tar.gz", hash = "sha256:7137ebd4d3bbf70ea1622998f902b97a29434a9e8dc40eb203bbcf7c2a2cba92", size = 37196, upload-time = "2025-12-17T09:24:22.446Z" } +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/aa/76/03af049af4dcee5d27442f71b6924f01f3efb5d2bd34f23fcd563f2cc5f5/python_multipart-0.0.21-py3-none-any.whl", hash = "sha256:cf7a6713e01c87aa35387f4774e812c4361150938d20d232800f75ffcf266090", size = 24541, upload-time = "2025-12-17T09:24:21.153Z" }, + { 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]] @@ -1853,6 +1878,15 @@ wheels = [ { 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" }, ] +[[package]] +name = "random-username" +version = "1.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/84/7004e0038707827e7ebe91a09e0f46483e36134de3b20bc0d5cf864a4caa/random-username-1.0.2.tar.gz", hash = "sha256:5fdc0604b5d1bdfe4acf4cd7491a9de1caf41bbdd890f646b434e09ae2a1b7ce", size = 3805, upload-time = "2019-01-11T22:57:45.038Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/d8/0f96b9bdea0683884b0ff2d8663b2580e2bad4418848540d1a12e1fd1d7b/random_username-1.0.2-py3-none-any.whl", hash = "sha256:2536feb63fecde7e01ede4a541aadb6f0b58794a7ab327ca5369d2a4b7664c06", size = 6673, upload-time = "2019-01-11T22:57:43.656Z" }, +] + [[package]] name = "requests" version = "2.33.1" @@ -1954,6 +1988,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] +[[package]] +name = "socksio" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/5c/48a7d9495be3d1c651198fd99dbb6ce190e2274d0f28b9051307bdec6b85/socksio-1.0.0.tar.gz", hash = "sha256:f88beb3da5b5c38b9890469de67d0cb0f9d494b78b106ca1845f96c10b91c4ac", size = 19055, upload-time = "2020-04-17T15:50:34.664Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/c3/6eeb6034408dac0fa653d126c9204ade96b819c936e136c5e8a6897eee9c/socksio-1.0.0-py3-none-any.whl", hash = "sha256:95dc1f15f9b34e8d7b16f06d74b8ccf48f609af32ab33c608d08761c5dcbb1f3", size = 12763, upload-time = "2020-04-17T15:50:31.878Z" }, +] + [[package]] name = "sqlalchemy" version = "1.4.54" @@ -1996,15 +2039,15 @@ wheels = [ [[package]] name = "starlette" -version = "0.47.1" +version = "0.48.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0a/69/662169fdb92fb96ec3eaee218cf540a629d629c86d7993d9651226a6789b/starlette-0.47.1.tar.gz", hash = "sha256:aef012dd2b6be325ffa16698f9dc533614fb1cebd593a906b90dc1025529a79b", size = 2583072, upload-time = "2025-06-21T04:03:17.337Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/a5/d6f429d43394057b67a6b5bbe6eae2f77a6bf7459d961fdb224bf206eee6/starlette-0.48.0.tar.gz", hash = "sha256:7e8cee469a8ab2352911528110ce9088fdc6a37d9876926e73da7ce4aa4c7a46", size = 2652949, upload-time = "2025-09-13T08:41:05.699Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/82/95/38ef0cd7fa11eaba6a99b3c4f5ac948d8bc6ff199aabd327a29cc000840c/starlette-0.47.1-py3-none-any.whl", hash = "sha256:5e11c9f5c7c3f24959edbf2dffdc01bba860228acf657129467d8a7468591527", size = 72747, upload-time = "2025-06-21T04:03:15.705Z" }, + { url = "https://files.pythonhosted.org/packages/be/72/2db2f49247d0a18b4f1bb9a5a39a0162869acf235f3a96418363947b3d46/starlette-0.48.0-py3-none-any.whl", hash = "sha256:0764ca97b097582558ecb498132ed0c7d942f233f365b86ba37770e026510659", size = 73736, upload-time = "2025-09-13T08:41:03.869Z" }, ] [[package]] @@ -2106,11 +2149,11 @@ wheels = [ [[package]] name = "urllib3" -version = "2.6.3" +version = "2.8.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, + { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" }, ] [[package]] @@ -2169,6 +2212,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/27/8d/edd0bd910ff803c308ee9a6b7778621af0d10252219ad9f19ef4d4982a61/virtualenv-21.2.4-py3-none-any.whl", hash = "sha256:29d21e941795206138d0f22f4e45ff7050e5da6c6472299fb7103318763861ac", size = 5831232, upload-time = "2026-04-14T22:15:29.342Z" }, ] +[[package]] +name = "wasmtime" +version = "49.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/48/7e/eb4b868529a2ae444cd24ea3c2cf1dd6dd2ce8d67ed4fb27b4e84529a1bf/wasmtime-49.0.0.tar.gz", hash = "sha256:fe0bf510ebdaf9432b756e320d2da2347f159e241f468c961b95164ab06cfdc1", size = 129562, upload-time = "2026-09-21T18:59:02.159Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/17/33c1df06ddf0ae5e085def2f4cbe1cc7aa79af1619f624e71cdca1c7138e/wasmtime-49.0.0-py3-none-android_26_arm64_v8a.whl", hash = "sha256:376c60e195b22bbe07b10d517ed4a83bbcc1896df0065e523dd4801b22570a8c", size = 8981381, upload-time = "2026-09-21T18:58:34.69Z" }, + { url = "https://files.pythonhosted.org/packages/9c/fc/a5b6fba6132de20494526a1b600612a4a0e10ca4c49f3fb005685ff0c86d/wasmtime-49.0.0-py3-none-android_26_x86_64.whl", hash = "sha256:0153c9d5fe061dd21e761f47cd65be36bf6656970c52df034e4db4871ad2b11a", size = 9928209, upload-time = "2026-09-21T18:58:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/17/3a/ff1b49ef5f66a2cfe6ff24eee44e092927fcc8e8374216fbd7de094320c7/wasmtime-49.0.0-py3-none-any.whl", hash = "sha256:3afc071340d9bb0cdf0fc0213e670c6c06ac193f2d73caf9b92d928496e49a51", size = 8262567, upload-time = "2026-09-21T18:58:40.244Z" }, + { url = "https://files.pythonhosted.org/packages/9f/1b/2595e986f3f283b2942c410d655917064e4e8126b082d354cb1dea4db2fc/wasmtime-49.0.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:839225359e01360974795bd7869c25d4305da96627df0af360b0668e20ecf1fb", size = 9460810, upload-time = "2026-09-21T18:58:42.183Z" }, + { url = "https://files.pythonhosted.org/packages/b7/c1/2333bb9b9364c28b4d113853d4e002e6ee3ee8d87d33b413dfdb220ce4d1/wasmtime-49.0.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:de98d6282c61d5dd4dc1ff1b410936ceb6fff3a27439e9cab51a375b8da2ec2f", size = 8438112, upload-time = "2026-09-21T18:58:44.747Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/d62b41a6ae9063681bb6af018ff49b75d49f853bbf672c7ddebedf21d69e/wasmtime-49.0.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:94f0288f9e1c33924995a72bb769f4c4e2885002391589dd6992cdaa35d1990a", size = 9919038, upload-time = "2026-09-21T18:58:47.212Z" }, + { url = "https://files.pythonhosted.org/packages/9c/f4/7889eb2fdbb11423e61f442af218441e0ae5749452bf8d2c1ade4ce630c5/wasmtime-49.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:263b924576d9519c43d5a1acff50a03ab03209d16d26c53543c9ab9a409fb93f", size = 8853040, upload-time = "2026-09-21T18:58:50.112Z" }, + { url = "https://files.pythonhosted.org/packages/d1/8b/fb187eaed30738ce4b1f8ecdbb0be1b4c1750269ed63605286e253153329/wasmtime-49.0.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:8ab7e74c730bdda9613a8cd28b54aafa9e7f5d64aaff1a808751ccb142a76994", size = 8911214, upload-time = "2026-09-21T18:58:52.449Z" }, + { url = "https://files.pythonhosted.org/packages/2d/b6/32cd30e1a38a06e01499aa50274a91b175a09b64754a06a0680e9881aa12/wasmtime-49.0.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7198903d53c63673acdbe4753594703d090842c4c9e050d2d4ff14ecfebed00f", size = 9948354, upload-time = "2026-09-21T18:58:56.016Z" }, + { url = "https://files.pythonhosted.org/packages/c7/41/b1d78d0ea1506abfa3005c8a50eee724b95a9cffbb2a0ed541f0965f5ab9/wasmtime-49.0.0-py3-none-win_amd64.whl", hash = "sha256:46a4e4220fd1bee45ad9c29a73cadad54373dad840eff104726490527bc7ef89", size = 8262572, upload-time = "2026-09-21T18:58:58.58Z" }, + { url = "https://files.pythonhosted.org/packages/5d/fe/484658110d9b6ff54f4c16104f1313489432568a7eeb61b5bcf2c045a5ab/wasmtime-49.0.0-py3-none-win_arm64.whl", hash = "sha256:95ab8d3da870f38202f11c0933650cbdf014885335fcd407d6ae1a6b3d647599", size = 7157718, upload-time = "2026-09-21T18:59:00.864Z" }, +] + [[package]] name = "websocket-client" version = "1.9.0"