Compare commits

..
Author SHA1 Message Date
dni ⚡ 1161fab805 feat: adding root path functionality
- add --root-path to lnbits application start to use in uvicorn
- utils.urlFor is aware of root-path
- api.request is aware of root-path

TODO: dynamic component loading kinda works, but older extension loading
is curently broken because of path issues on `normalize_path`

unhardcode api

fixup nocache

unhardcode

feat: adding root path functionality

- add --root-path to lnbits application start to use in uvicorn
- utils.urlFor is aware of root-path
- api.request is aware of root-path

TODO: dynamic component loading kinda works, but older extension loading
is curently broken because of path issues on `normalize_path`
2026-06-08 16:53:40 +02:00
51 changed files with 370 additions and 1687 deletions
+3 -7
View File
@@ -10,16 +10,15 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.head_ref || github.event.pull_request.head.sha }}
ref: ${{ github.head_ref }}
- uses: lnbits/lnbits/.github/actions/prepare@dev
with:
python-version: "3.10"
node-version: "24.x"
npm: true
- name: Build and commit bundle (same-repo PR)
if: github.event.pull_request.head.repo.full_name == github.repository
- run: make bundle
- name: Commit and push bundle changes
run: |
make bundle
git config user.name "alan"
git config user.email "alan@lnbits.com"
git add lnbits/static
@@ -28,6 +27,3 @@ jobs:
fi
git commit -m "chore: make bundle [skip ci]"
git push
- name: Check bundle is up-to-date (fork PR)
if: github.event.pull_request.head.repo.full_name != github.repository
run: make checkbundle
-1
View File
@@ -66,7 +66,6 @@ jobs:
BOLTZ_MNEMONIC: abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about
LNBITS_MAX_OUTGOING_PAYMENT_AMOUNT_SATS: 1000000000
LNBITS_MAX_INCOMING_PAYMENT_AMOUNT_SATS: 1000000000
LNBITS_FUNDING_SOURCE_PAY_INVOICE_WAIT_SECONDS: ${{ inputs.backend-wallet-class == 'CoreLightningRestWallet' && 60 || 5 }}
ECLAIR_PASS: lnbits
PYTHONUNBUFFERED: 1
DEBUG: true
-4
View File
@@ -40,7 +40,6 @@ from lnbits.core.tasks import (
)
from lnbits.exceptions import register_exception_handlers
from lnbits.helpers import version_parse
from lnbits.llms_txt import create_llms_txt_route
from lnbits.settings import settings
from lnbits.tasks import (
cancel_all_tasks,
@@ -103,9 +102,6 @@ async def startup(app: FastAPI):
# register core routes
init_core_routers(app)
# register llms.txt endpoint for AI agents
create_llms_txt_route(app)
# initialize tasks
register_async_tasks()
-39
View File
@@ -16,7 +16,6 @@ from ..models import (
PaymentFilters,
PaymentHistoryPoint,
PaymentsStatusCount,
PaymentTotalBreakdown,
PaymentWalletStats,
)
@@ -429,44 +428,6 @@ async def get_payment_count_stats(
return data
async def get_wallet_payment_total_breakdown(
wallet_id: str,
conn: Connection | None = None,
) -> list[PaymentTotalBreakdown]:
wallet = await get_wallet(wallet_id, conn=conn)
if not wallet or not wallet.can_view_payments:
return []
values = {"wallet_id": wallet.source_wallet_id}
data = await (conn or db).fetchall(
query=f"""
SELECT tag,
CASE
WHEN fiat_provider IS NOT NULL
OR checking_id LIKE 'fiat_%'
OR extra LIKE '%"fiat_payment_request"%'
OR extra LIKE '%"fiat_amount"%'
THEN true
ELSE false
END AS is_fiat,
COUNT(*) AS payments_count,
SUM(amount - ABS(fee)) AS total
FROM apipayments
WHERE wallet_id = :wallet_id
AND (
status = '{PaymentState.SUCCESS}'
OR (amount < 0 AND status = '{PaymentState.PENDING}')
)
GROUP BY tag, is_fiat
ORDER BY tag
""", # noqa: S608
values=values,
model=PaymentTotalBreakdown,
)
return data
async def get_daily_stats(
filters: Filters[PaymentFilters] | None = None,
user_id: str | None = None,
-2
View File
@@ -23,7 +23,6 @@ from .payments import (
PaymentHistoryPoint,
PaymentsStatusCount,
PaymentState,
PaymentTotalBreakdown,
PaymentWalletStats,
SettleInvoice,
UpdatePaymentExtra,
@@ -84,7 +83,6 @@ __all__ = [
"PaymentFilters",
"PaymentHistoryPoint",
"PaymentState",
"PaymentTotalBreakdown",
"PaymentWalletStats",
"PaymentsStatusCount",
"RegisterUser",
+2 -13
View File
@@ -55,10 +55,9 @@ class GitHubRelease(BaseModel):
class Manifest(BaseModel):
featured: list[str] = []
extensions: list[ExplicitRelease] = []
repos: list[GitHubRelease] = []
featured: list[str] = []
categories: dict[str, list[str]] = {}
class GitHubRepoRelease(BaseModel):
@@ -309,6 +308,7 @@ class ExtensionRelease(BaseModel):
@classmethod
async def fetch_release_details(cls, details_link: str) -> dict | None:
try:
async with httpx.AsyncClient() as client:
resp = await client.get(details_link)
@@ -333,7 +333,6 @@ class ExtensionMeta(BaseModel):
dependencies: list[str] = []
archive: str | None = None
featured: bool = False
categories: list[str] = []
paid_features: str | None = None
has_paid_release: bool = False
has_free_release: bool = False
@@ -681,11 +680,6 @@ class InstallableExtension(BaseModel):
meta = ext.meta or ExtensionMeta()
meta.featured = ext.id in manifest.featured
meta.categories = [
category
for category, ext_ids in manifest.categories.items()
if ext.id in ext_ids
]
ext.meta = meta
extension_list += [ext]
@@ -701,11 +695,6 @@ class InstallableExtension(BaseModel):
ext.check_release_updates(release)
meta = ext.meta or ExtensionMeta()
meta.featured = ext.id in manifest.featured
meta.categories = [
category
for category, ext_ids in manifest.categories.items()
if ext.id in ext_ids
]
ext.meta = meta
extension_list += [ext]
except Exception as e:
+8 -11
View File
@@ -140,18 +140,22 @@ class Payment(BaseModel):
)
# DEPRECATED: in v1.5.0, use service check_payment_status instead
async def check_status(self) -> PaymentStatus:
async def check_status(
self, skip_internal_payment_notifications: bool | None = False
) -> PaymentStatus:
logger.warning("payment.check_status() is deprecated.")
from lnbits.core.services.payments import check_payment_status
return await check_payment_status(self)
return await check_payment_status(self, skip_internal_payment_notifications)
# DEPRECATED: in v1.5.0, use service check_payment_status instead
async def check_fiat_status(self) -> FiatPaymentStatus:
async def check_fiat_status(
self, skip_internal_payment_notifications: bool | None = False
) -> FiatPaymentStatus:
logger.warning("payment.check_fiat_status() is deprecated.")
from lnbits.core.services.fiat_providers import check_fiat_status
return await check_fiat_status(self)
return await check_fiat_status(self, skip_internal_payment_notifications)
class PaymentFilters(FilterModel):
@@ -220,13 +224,6 @@ class PaymentWalletStats(BaseModel):
balance: float = 0
class PaymentTotalBreakdown(BaseModel):
tag: str | None = None
is_fiat: bool = False
payments_count: int = 0
total: int = 0
class PaymentDailyStats(BaseModel):
date: datetime
balance: float = 0
+7 -6
View File
@@ -8,7 +8,7 @@ import httpx
from loguru import logger
from lnbits.core.crud import get_wallet
from lnbits.core.crud.payments import create_payment, update_payment
from lnbits.core.crud.payments import create_payment
from lnbits.core.models import CreatePayment, Payment, PaymentState
from lnbits.core.models.misc import SimpleStatus
from lnbits.db import Connection
@@ -36,7 +36,9 @@ async def handle_fiat_payment_confirmation(
logger.warning(e)
async def check_fiat_status(payment: Payment) -> FiatPaymentStatus:
async def check_fiat_status(
payment: Payment, skip_internal_payment_notifications: bool | None = False
) -> FiatPaymentStatus:
if not payment.is_internal:
return FiatPaymentPendingStatus()
if payment.success:
@@ -56,11 +58,10 @@ async def check_fiat_status(payment: Payment) -> FiatPaymentStatus:
return FiatPaymentPendingStatus()
fiat_status = await fiat_provider.get_invoice_status(checking_id)
if fiat_status.success:
payment.status = PaymentState.SUCCESS.value
await update_payment(payment)
await handle_fiat_payment_confirmation(payment)
if skip_internal_payment_notifications:
return fiat_status
if fiat_status.success:
# notify receivers asynchronously
from lnbits.tasks import internal_invoice_queue
+6 -35
View File
@@ -74,7 +74,7 @@ async def send_admin_notification(
message: str,
message_type: str | None = None,
) -> None:
return await send_notification_in_background(
return await send_notification(
settings.lnbits_telegram_notifications_chat_id,
settings.lnbits_nostr_notifications_identifiers,
settings.lnbits_email_notifications_to_emails,
@@ -97,7 +97,7 @@ async def send_user_notification(
if user_notifications.nostr_identifier
else []
)
return await send_notification_in_background(
return await send_notification(
user_notifications.telegram_chat_id,
nostr_identifiers,
email_address,
@@ -222,20 +222,12 @@ async def send_email(
msg["Subject"] = subject
msg.attach(MIMEText(message, "plain"))
username = username if len(username) > 0 else from_email
def _send() -> bool:
with smtplib.SMTP(server, port) as smtp_server:
smtp_server.starttls()
smtp_server.login(username, password)
smtp_server.sendmail(from_email, to_emails, msg.as_string())
with smtplib.SMTP(server, port) as smtp_server:
smtp_server.starttls()
smtp_server.login(username, password)
smtp_server.sendmail(from_email, to_emails, msg.as_string())
return True
try:
return await asyncio.to_thread(_send)
except Exception as e:
logger.warning(f"Sending Email failed. {e!s}")
return False
async def dispatch_webhook(payment: Payment):
"""
@@ -302,27 +294,6 @@ def send_payment_notification_in_background(wallet: Wallet, payment: Payment):
logger.warning(f"Error sending payment notification: {e}")
async def send_notification_in_background(
telegram_chat_id: str | None,
nostr_identifiers: list[str] | None,
email_addresses: list[str] | None,
message: str,
message_type: str | None = None,
):
try:
create_task(
send_notification(
telegram_chat_id,
nostr_identifiers,
email_addresses,
message,
message_type,
)
)
except Exception as e:
logger.warning(f"Error sending notification in background: {e}")
async def send_ws_payment_notification(wallet: Wallet, payment: Payment):
# TODO: websocket message should be a clean payment model
# await websocket_manager.send(wallet.inkey, payment.json())
+22 -22
View File
@@ -13,6 +13,7 @@ from lnbits.core.crud.payments import get_daily_stats
from lnbits.core.db import db
from lnbits.core.models import PaymentDailyStats, PaymentFilters
from lnbits.core.models.payments import CreateInvoice
from lnbits.core.services.fiat_providers import handle_fiat_payment_confirmation
from lnbits.db import Connection, Filters
from lnbits.decorators import check_user_extension_access
from lnbits.exceptions import InvoiceError, PaymentError, UnsupportedError
@@ -629,14 +630,18 @@ async def check_transaction_status(
return await check_payment_status(payment)
async def check_payment_status(payment: Payment) -> PaymentStatus:
async def check_payment_status(
payment: Payment, skip_internal_payment_notifications: bool | None = False
) -> PaymentStatus:
if payment.is_internal:
if payment.success:
return PaymentSuccessStatus()
if payment.failed:
return PaymentFailedStatus()
if payment.is_in and payment.fiat_provider:
fiat_status = await check_fiat_status(payment)
fiat_status = await check_fiat_status(
payment, skip_internal_payment_notifications
)
return PaymentStatus(paid=fiat_status.paid)
return PaymentPendingStatus()
funding_source = get_funding_source()
@@ -778,14 +783,9 @@ async def _pay_internal_invoice(
await update_payment(internal_payment, conn=conn)
logger.success(f"internal payment successful {internal_payment.checking_id}")
await _send_payment_notification_in_background(
wallet.id, payment, conn=conn
) # notify the sender
await _send_payment_notification_in_background(
internal_payment.wallet_id, internal_payment, conn=conn
) # notify the receiver
await _send_payment_notification_in_background(wallet.id, payment, conn=conn)
# notify receiver asynchronously (extension listeners)
# notify receiver asynchronously
from lnbits.tasks import internal_invoice_queue
logger.debug(f"enqueuing internal invoice {internal_payment.checking_id}")
@@ -1079,29 +1079,29 @@ async def _send_payment_notification_in_background(
send_payment_notification_in_background(wallet, payment)
async def update_invoice_from_paid_invoices_stream(checking_id: str) -> Payment | None:
async def update_invoice_callback(checking_id: str) -> Payment | None:
"""
Takes a checking_id of an incoming payment from paid_invoices_stream()
Checks its status, updates its status and returns it.
returns None if no incoming payment was found or the status is not successful
Takes a checking_id of an incoming payment, from either paid_invoices_stream()
or internal_invoice_queue. Checks its status, updates and returns it.
returns None if no payment was found or it not and incoming payment.
"""
payment = await get_standalone_payment(checking_id, incoming=True)
if not payment:
logger.warning(f"No incoming payment found for '{checking_id}'.")
logger.warning(f"No payment found for '{checking_id}'.")
return None
status = await check_payment_status(payment)
if not status.success:
logger.error(
"Unexpected status response from paid_invoices_stream. Skipping update."
)
if not payment.is_in:
logger.warning(f"Payment '{checking_id}' is not incoming, skipping.")
return None
status = await check_payment_status(
payment, skip_internal_payment_notifications=True
)
payment.fee = status.fee_msat or payment.fee
# only overwrite preimage if status.preimage provides it
payment.preimage = status.preimage or payment.preimage
payment.status = PaymentState.SUCCESS
payment = await update_payment(payment)
if payment.fiat_provider:
await handle_fiat_payment_confirmation(payment)
return payment
-6
View File
@@ -25,7 +25,6 @@ from lnbits.core.services.notifications import (
)
from lnbits.db import Filters
from lnbits.settings import settings
from lnbits.utils.cache import cache
from lnbits.utils.exchange_rates import btc_rates
audit_queue: asyncio.Queue[AuditEntry] = asyncio.Queue()
@@ -156,11 +155,6 @@ async def collect_exchange_rates_data() -> None:
rates_values = [r[1] for r in rates]
lnbits_rate = sum(rates_values) / len(rates_values)
rates.append(("LNbits", lnbits_rate))
cache.set(
f"btc-price-{currency}",
lnbits_rate,
expiry=settings.lnbits_exchange_rate_cache_seconds,
)
settings.append_exchange_rate_datapoint(dict(rates), max_history_size)
except Exception as ex:
logger.warning(ex)
+1 -1
View File
@@ -439,7 +439,7 @@ async def _handle_revolut_subscription(
extra = {
**(reference.extra or {}),
"subscription_request_id": subscription_id,
"subscription_request_id": reference.subscription_request_id,
"fiat_method": "subscription",
"tag": reference.tag,
"subscription": {
+1 -1
View File
@@ -292,6 +292,7 @@ async def api_deactivate_extension(ext_id: str) -> SimpleStatus:
@extension_router.delete("/{ext_id}", dependencies=[Depends(check_admin)])
async def api_uninstall_extension(ext_id: str) -> SimpleStatus:
extension = await get_installed_extension(ext_id)
if not extension:
raise HTTPException(
@@ -566,7 +567,6 @@ async def extensions(account_id: AccountId = Depends(check_account_id_exists)):
"shortDescription": ext.short_description,
"stars": ext.stars,
"isFeatured": ext.meta.featured if ext.meta else False,
"categories": ext.meta.categories if ext.meta else [],
"dependencies": ext.meta.dependencies if ext.meta else "",
"isInstalled": ext.id in installed_exts_ids,
"hasDatabaseTables": next(
+1 -1
View File
@@ -32,7 +32,7 @@ generic_router = APIRouter(
@generic_router.get("/favicon.ico", response_class=FileResponse)
async def favicon():
return RedirectResponse(settings.lnbits_qr_logo)
return RedirectResponse(settings.root_path + settings.lnbits_qr_logo)
@generic_router.get("/robots.txt", response_class=HTMLResponse)
-13
View File
@@ -14,7 +14,6 @@ from lnurl import url_decode
from lnbits import bolt11
from lnbits.core.crud.payments import (
get_payment_count_stats,
get_wallet_payment_total_breakdown,
get_wallets_stats,
update_payment,
)
@@ -32,7 +31,6 @@ from lnbits.core.models import (
PaymentDailyStats,
PaymentFilters,
PaymentHistoryPoint,
PaymentTotalBreakdown,
PaymentWalletStats,
SettleInvoice,
SimpleStatus,
@@ -133,17 +131,6 @@ async def api_payments_counting_stats(
return await get_payment_count_stats(count_by, filters=filters, user_id=for_user_id)
@payment_router.get(
"/stats/breakdown",
name="Get wallet payment total breakdown",
response_model=list[PaymentTotalBreakdown],
)
async def api_payments_total_breakdown(
key_info: BaseWalletTypeInfo = Depends(require_base_invoice_key),
):
return await get_wallet_payment_total_breakdown(key_info.wallet.id)
@payment_router.get(
"/stats/wallets",
name="Get payments history for all users",
+2
View File
@@ -517,6 +517,7 @@ async def _check_account_api_access(
raise HTTPException(HTTPStatus.FORBIDDEN, "Method not allowed.")
# TODO: this messes up my extension urls
def url_for_interceptor(original_method):
def normalize_url(self, *args, **kwargs):
url = original_method(self, *args, **kwargs)
@@ -527,6 +528,7 @@ def url_for_interceptor(original_method):
# Upgraded extensions modify the path.
# This interceptor ensures that the path is normalized.
# TODO: this messes up my extension urls
Request.url_for = url_for_interceptor(Request.url_for) # type: ignore[method-assign]
+1 -10
View File
@@ -283,7 +283,7 @@ class RevolutWallet(FiatProvider):
return FiatSubscriptionResponse(
ok=True,
checkout_session_url=checkout_url,
subscription_request_id=revolut_subscription_id,
subscription_request_id=payment_options.subscription_request_id,
)
except json.JSONDecodeError:
return FiatSubscriptionResponse(
@@ -302,15 +302,6 @@ class RevolutWallet(FiatProvider):
**kwargs,
) -> FiatSubscriptionResponse:
try:
subscription = await self.get_subscription(subscription_id)
reference = self.deserialize_subscription_reference(
subscription.get("external_reference")
)
if not reference or reference.wallet_id != correlation_id:
return FiatSubscriptionResponse(
ok=False, error_message="Subscription not found."
)
r = await self.client.post(
f"/api/subscriptions/{subscription_id}/cancel",
timeout=REVOLUT_REQUEST_TIMEOUT,
+17 -4
View File
@@ -48,8 +48,11 @@ def url_for(endpoint: str, external: bool | None = False, **params: Any) -> str:
return url
def static_url_for(static: str, path: str) -> str:
return f"/{static}/{path}?v={settings.server_startup_time}"
def static_url_for(static: str, path: str, no_cache: bool = False) -> str:
url = f"{settings.root_path}{static}/{path}"
if no_cache:
url += f"?v={settings.server_startup_time}"
return url
def template_renderer(additional_folders: list | None = None) -> Jinja2Templates:
@@ -57,7 +60,6 @@ def template_renderer(additional_folders: list | None = None) -> Jinja2Templates
"lnbits/templates",
settings.extension_builder_working_dir_path.as_posix(),
]
if additional_folders:
additional_folders += [
Path(settings.lnbits_extensions_path, "extensions", f)
@@ -69,6 +71,7 @@ def template_renderer(additional_folders: list | None = None) -> Jinja2Templates
t.env.globals["normalize_path"] = normalize_path
# used in base.html
t.env.globals["ROOT_PATH"] = settings.root_path
t.env.globals["SITE_TITLE"] = settings.lnbits_site_title
t.env.globals["LNBITS_APPLE_TOUCH_ICON"] = settings.lnbits_apple_touch_icon
t.env.globals["SETTINGS"] = settings.to_public().dict(by_alias=True)
@@ -310,6 +313,8 @@ def get_api_routes(routes: list) -> dict[str, str]:
def path_segments(path: str) -> list[str]:
path = path.strip("/")
# Remove empty segments caused by '//' in the path
# segments = [s for s in path.split("/") if s]
segments = path.split("/")
if len(segments) < 2:
return segments
@@ -319,8 +324,16 @@ def path_segments(path: str) -> list[str]:
def normalize_path(path: str | None) -> str:
print(path)
path = path or ""
return "/" + "/".join(path_segments(path))
segments = path_segments(path)
print(segments)
joined = "/".join(segments)
print("!!!!!!!!!!")
print(joined)
return joined
# return "/" + "/".join(path_segments(path))
def normalize_endpoint(endpoint: str, add_proto=True) -> str:
-78
View File
@@ -1,78 +0,0 @@
"""Generate llms.txt markdown from FastAPI OpenAPI schema for AI agents."""
from typing import Any
from fastapi import FastAPI
from fastapi.responses import PlainTextResponse
def generate_llms_txt(app: FastAPI) -> str:
"""Convert an OpenAPI schema to llms.txt markdown format."""
openapi_schema = app.openapi()
lines: list[str] = []
# H1: API Title
info = openapi_schema.get("info", {})
title = info.get("title", "API")
lines.append(f"# {title}")
lines.append("")
# Blockquote: Description
description = info.get("description")
if description:
for line in description.strip().split("\n"):
lines.append(f"> {line}")
lines.append("")
# Group endpoints by tag
paths = openapi_schema.get("paths", {})
endpoints_by_tag: dict[str, list[dict[str, Any]]] = {}
for path, path_item in paths.items():
for method in ["get", "post", "put", "patch", "delete", "head", "options"]:
if method not in path_item:
continue
operation = path_item[method]
tags = operation.get("tags", ["Endpoints"])
tag = tags[0] if tags else "Endpoints"
if tag not in endpoints_by_tag:
endpoints_by_tag[tag] = []
endpoints_by_tag[tag].append(
{
"path": path,
"method": method.upper(),
"operation": operation,
}
)
# Generate sections by tag
for tag, endpoints in endpoints_by_tag.items():
lines.append(f"## {tag}")
lines.append("")
for endpoint in endpoints:
method = endpoint["method"]
path = endpoint["path"]
operation = endpoint["operation"]
summary = operation.get("summary", "")
if summary:
lines.append(f"### `{method} {path}` - {summary}")
else:
lines.append(f"### `{method} {path}`")
lines.append("")
lines.append("")
return "\n".join(lines).strip() + "\n"
def create_llms_txt_route(app: FastAPI) -> None:
"""Add a /llms.txt endpoint to the app."""
@app.get(
"/llms.txt",
response_class=PlainTextResponse,
include_in_schema=False,
summary="Get LLM-friendly API documentation",
)
async def get_llms_txt() -> str:
"""Return the API documentation in llms.txt markdown format."""
return generate_llms_txt(app)
+3 -1
View File
@@ -20,6 +20,7 @@ from lnbits.helpers import normalize_path, template_renderer
from lnbits.settings import settings
# TODO: root path should be considered here?
class InstalledExtensionMiddleware:
# This middleware class intercepts calls made to the extensions API and:
# - it blocks the calls if the extension has been disabled or uninstalled.
@@ -51,7 +52,7 @@ class InstalledExtensionMiddleware:
await self.app(scope, receive, send)
return
# re-route all trafic if the extension has been upgraded
# re-route all traffic if the extension has been upgraded
if top_path in settings.lnbits_upgraded_extensions:
upgrade_path = (
f"""{settings.lnbits_upgraded_extensions[top_path]}/{top_path}"""
@@ -241,6 +242,7 @@ def add_first_install_middleware(app: FastAPI):
async def first_install_middleware(request: Request, call_next):
if (
settings.first_install
# TODO: root path should be considered here?
and request.url.path != "/api/v1/auth/first_install"
and request.url.path != "/first_install"
and not request.url.path.startswith("/static")
+13 -1
View File
@@ -17,6 +17,11 @@ from lnbits.settings import set_cli_settings, settings
)
@click.option("--port", default=settings.port, help="Port to listen on")
@click.option("--host", default=settings.host, help="Host to run LNbits on")
@click.option(
"--root-path",
default=settings.root_path,
help="Root path of proxy, my.lnbits.com/rootpath ",
)
@click.option(
"--forwarded-allow-ips",
default=settings.forwarded_allow_ips,
@@ -30,6 +35,7 @@ from lnbits.settings import set_cli_settings, settings
def main(
port: int,
host: str,
root_path: str,
forwarded_allow_ips: str,
ssl_keyfile: str,
ssl_certfile: str,
@@ -46,7 +52,12 @@ def main(
parents=True, exist_ok=True
)
set_cli_settings(host=host, port=port, forwarded_allow_ips=forwarded_allow_ips)
set_cli_settings(
host=host,
port=port,
forwarded_allow_ips=forwarded_allow_ips,
root_path=root_path,
)
while True:
config = uvicorn.Config(
@@ -54,6 +65,7 @@ def main(
loop="uvloop",
port=port,
host=host,
root_path=root_path,
forwarded_allow_ips=forwarded_allow_ips,
ssl_keyfile=ssl_keyfile,
ssl_certfile=ssl_certfile,
+2 -1
View File
@@ -359,7 +359,7 @@ class FeeSettings(LNbitsSettings):
class ExchangeProvidersSettings(LNbitsSettings):
lnbits_exchange_rate_cache_seconds: int = Field(default=60, ge=0)
lnbits_exchange_rate_cache_seconds: int = Field(default=30, ge=0)
lnbits_exchange_history_size: int = Field(default=60, ge=0)
lnbits_exchange_history_refresh_interval_seconds: int = Field(default=300, ge=0)
@@ -1060,6 +1060,7 @@ class EnvSettings(LNbitsSettings):
auth_https_only: bool = Field(default=True)
host: str = Field(default="127.0.0.1")
port: int = Field(default=5000, gt=0)
root_path: str = Field(default="/")
forwarded_allow_ips: str = Field(default="*")
lnbits_title: str = Field(default="LNbits API")
lnbits_path: str = Field(default=".")
File diff suppressed because one or more lines are too long
+10 -10
View File
File diff suppressed because one or more lines are too long
-1
View File
@@ -184,7 +184,6 @@ window.localisation.en = {
release_notes: 'Release Notes',
activate_extension_details: 'Make extension available/unavailable for users',
featured: 'Featured',
categories: 'Categories',
all: 'All',
only_admins_can_install: '(Only admin accounts can install extensions)',
only_admins_can_create_extensions:
+19 -54
View File
@@ -1,5 +1,7 @@
window._lnbitsApi = {
request(method, url, apiKey, data, options = {}) {
url = ROOT_PATH + url.replace(/^\/+/, '') // Ensure single slash after rootPath
console.log(`API Request: ${method.toUpperCase()} ${url}`)
return axios({
method: method,
url: url,
@@ -67,75 +69,41 @@ window._lnbitsApi = {
})
},
register(username, email, password, password_repeat, invitation_code) {
return axios({
method: 'POST',
url: '/api/v1/auth/register',
data: {
username,
email,
password,
password_repeat,
invitation_code
}
return this.request('post', '/api/v1/auth/register', null, {
username,
email,
password,
password_repeat,
invitation_code
})
},
reset(reset_key, password, password_repeat) {
return axios({
method: 'PUT',
url: '/api/v1/auth/reset',
data: {
reset_key,
password,
password_repeat
}
return this.request('put', '/api/v1/auth/reset', null, {
reset_key,
password,
password_repeat
})
},
getAuthUser() {
return axios({
method: 'GET',
url: '/api/v1/auth'
})
return this.request('get', '/api/v1/auth')
},
login(username, password) {
return axios({
method: 'POST',
url: '/api/v1/auth',
data: {username, password}
})
return this.request('post', '/api/v1/auth', null, {username, password})
},
loginByProvider(provider, headers, data) {
return axios({
method: 'POST',
url: `/api/v1/auth/${provider}`,
headers: headers,
data
})
return this.request('post', `/api/v1/auth/${provider}`, null, data)
},
loginUsr(usr) {
return axios({
method: 'POST',
url: '/api/v1/auth/usr',
data: {usr}
})
return this.request('post', '/api/v1/auth/usr', null, {usr})
},
logout() {
return axios({
method: 'POST',
url: '/api/v1/auth/logout'
})
return this.request('post', '/api/v1/auth/logout')
},
impersonateUser(usr) {
return axios({
method: 'POST',
url: '/api/v1/auth/impersonate',
data: {usr}
})
return this.request('POST', '/api/v1/auth/impersonate', null, {usr})
},
stopImpersonation() {
return axios({
method: 'DELETE',
url: '/api/v1/auth/impersonate'
})
return this.request('DELETE', '/api/v1/auth/impersonate')
},
getAuthenticatedUser() {
return this.request('get', '/api/v1/auth')
@@ -175,9 +143,6 @@ window._lnbitsApi = {
wallet.inkey
)
},
getPaymentTotalBreakdown(wallet) {
return this.request('get', '/api/v1/payments/stats/breakdown', wallet.inkey)
},
getPayment(wallet, paymentHash) {
return this.request('get', '/api/v1/payments/' + paymentHash, wallet.inkey)
},
+6 -4
View File
@@ -72,7 +72,7 @@ window.dateFormat = 'YYYY-MM-DD HH:mm'
const websocketPrefix =
window.location.protocol === 'http:' ? 'ws://' : 'wss://'
const websocketUrl = `${websocketPrefix}${window.location.host}/api/v1/ws`
const websocketUrl = `${websocketPrefix}${window.location.host}${ROOT_PATH}api/v1/ws`
const _access_cookies_for_safari_refresh_do_not_delete = document.cookie
@@ -87,9 +87,11 @@ addEventListener('online', event => {
})
if (navigator.serviceWorker != null) {
navigator.serviceWorker.register('/service-worker.js').then(registration => {
console.log('Registered events at scope: ', registration.scope)
})
navigator.serviceWorker
.register(ROOT_PATH + 'service-worker.js')
.then(registration => {
console.log('Registered events at scope: ', registration.scope)
})
}
if (navigator.mediaDevices && navigator.mediaDevices.enumerateDevices) {
+19 -38
View File
@@ -11,11 +11,16 @@ const quasarConfig = {
const DynamicComponent = {
async created() {
// no trailing /
const rootPath = ROOT_PATH.replace(/\/+$/, '')
const name = this.$route.path.split('/')[1]
const path = `/${name}/`
const routesPath = `/${name}/static/routes.json`
const path = `${rootPath}/${name}/`
const routesPath = `${rootPath}/${name}/static/routes.json`
if (this.$router.getRoutes().some(r => r.path === path)) return
if (this.$route.fullPath.startsWith('/extensions/builder/preview')) return
if (
this.$route.fullPath.startsWith(rootPath + '/extensions/builder/preview')
)
return
fetch(routesPath)
.then(async res => {
if (!res.ok) throw new Error('No dynamic routes found')
@@ -27,14 +32,6 @@ const DynamicComponent = {
name: r.name,
component: async () => {
await LNbits.utils.loadTemplate(r.template)
if (r.i18n) {
const locale =
window.i18n?.global?.locale?.value ??
window.i18n?.global?.locale ??
window.g.locale ??
'en'
await LNbits.utils.loadExtI18n(r.i18n, locale)
}
await LNbits.utils.loadScript(r.component)
return window[r.name]
}
@@ -46,9 +43,17 @@ const DynamicComponent = {
let route = RENDERED_ROUTE
// append trailing slash only on the root path `/path` -> `/path/`
if (route.split('/').length === 2) route += '/'
console.log('ROUTE', route)
console.log('path / fullpath', this.$route.path, this.$route.fullPath)
if (route !== this.$route.path) {
console.log('Redirecting to non-vue route:', this.$route.fullPath)
window.location = this.$route.fullPath
const rootPath = ROOT_PATH.replace(/\/+$/, '')
console.log(
'Redirecting to non-vue route:',
rootPath + this.$route.fullPath
)
// window.location = rootPath + this.$route.fullPath
return
}
})
@@ -147,7 +152,7 @@ const routes = [
]
window.router = VueRouter.createRouter({
history: VueRouter.createWebHistory(),
history: VueRouter.createWebHistory(ROOT_PATH),
routes
})
@@ -159,30 +164,6 @@ window.i18n = new VueI18n.createI18n({
fallbackLocale: 'en',
messages: window.localisation
})
;(function () {
let _applying = false
let _target = null
Vue.watch(
() => window.i18n.global.locale,
async (locale, prevLocale) => {
if (_applying || !LNbits.utils._extI18nDirs.size) return
_target = locale
_applying = true
window.i18n.global.locale = prevLocale
_applying = false
await Promise.all(
[...LNbits.utils._extI18nDirs].map(dir =>
LNbits.utils.loadExtI18n(dir, locale)
)
)
if (_target !== locale) return
_applying = true
window.i18n.global.locale = locale
_applying = false
},
{flush: 'sync'}
)
})()
window.app.mixin({
data() {
-9
View File
@@ -10,7 +10,6 @@ window.PageExtensions = {
tab: 'installed',
manageExtensionTab: 'releases',
filteredExtensions: [],
categories: new Set(),
updatableExtensions: [],
showUninstallDialog: false,
showManageExtensionDialog: false,
@@ -107,10 +106,6 @@ window.PageExtensions = {
}
}
const isCategoryTab = !['installed', 'all', 'featured'].includes(tab)
const isInSelectedCategory = extension =>
extension.categories?.includes(tab) ?? false
this.filteredExtensions = this.extensions
.filter(e => (tab === 'all' ? !e.isInstalled : true))
.filter(e => (tab === 'installed' ? e.isInstalled : true))
@@ -118,7 +113,6 @@ window.PageExtensions = {
tab === 'installed' ? (e.isActive ? true : !!this.g.user.admin) : true
)
.filter(e => (tab === 'featured' ? e.isFeatured : true))
.filter(e => (isCategoryTab ? isInSelectedCategory(e) : true))
.filter(extensionNameContains(term))
.map(e => ({
...e,
@@ -838,9 +832,6 @@ window.PageExtensions = {
async fetchAllExtensions() {
try {
const {data} = await LNbits.api.request('GET', `/api/v1/extension/all`)
data.forEach(ext => {
ext.categories?.forEach(category => this.categories.add(category))
})
return data
} catch (error) {
console.warn(error)
-120
View File
@@ -48,13 +48,6 @@ window.PageWallet = {
hasNfc: false,
nfcReaderAbortController: null,
formattedFiatAmount: 0,
totalBreakdown: {
show: false,
loading: false,
rows: [],
selectedTypes: ['bitcoin', 'fiat'],
selectedTags: []
},
paymentFilter: {
'status[ne]': 'failed'
},
@@ -91,116 +84,9 @@ window.PageWallet = {
},
formattedSatAmount() {
return LNbits.utils.formatMsat(this.receive.amountMsat) + ' sat'
},
totalBreakdownTags() {
const tags = this.totalBreakdown.rows.map(row => row.tag || null)
return [...new Set(tags)].sort((a, b) =>
this.totalBreakdownTagLabel(a).localeCompare(
this.totalBreakdownTagLabel(b)
)
)
},
hasFiatTotalBreakdown() {
return this.totalBreakdown.rows.some(row => row.is_fiat)
},
selectedTotalBreakdownRows() {
return this.totalBreakdown.rows.filter(row => {
const type = row.is_fiat ? 'fiat' : 'bitcoin'
return (
this.totalBreakdown.selectedTypes.includes(type) &&
this.totalBreakdown.selectedTags.includes(
this.totalBreakdownTagKey(row.tag)
)
)
})
},
selectedTotalBreakdownMsat() {
return this.selectedTotalBreakdownRows.reduce(
(total, row) => total + row.total,
0
)
},
selectedTotalBreakdownSat() {
return Math.round(this.selectedTotalBreakdownMsat / 1000)
},
selectedTotalBreakdownCount() {
return this.selectedTotalBreakdownRows.reduce(
(total, row) => total + row.payments_count,
0
)
},
formattedTotalBreakdown() {
return this.utils.formatBalance(
this.selectedTotalBreakdownSat,
this.g.denomination
)
},
formattedTotalBreakdownFiat() {
if (!this.g.fiatTracking) return null
const amount =
(this.selectedTotalBreakdownSat / 100000000) * this.g.exchangeRate
return LNbits.utils.formatCurrency(amount, this.g.wallet.currency)
},
primaryTotalBreakdownValue() {
if (this.g.isFiatPriority && this.g.fiatTracking) {
return this.formattedTotalBreakdownFiat || this.formattedTotalBreakdown
}
return this.formattedTotalBreakdown
},
secondaryTotalBreakdownValue() {
if (!this.g.fiatTracking) return null
if (this.g.isFiatPriority) {
return this.formattedTotalBreakdown
}
return this.formattedTotalBreakdownFiat
}
},
methods: {
showWalletTotalBreakdown() {
this.totalBreakdown.show = true
if (!this.totalBreakdown.rows.length) {
this.fetchTotalBreakdown()
}
},
fetchTotalBreakdown() {
this.totalBreakdown.loading = true
LNbits.api
.getPaymentTotalBreakdown(this.g.wallet)
.then(response => {
this.totalBreakdown.rows = response.data
this.totalBreakdown.selectedTypes = ['bitcoin', 'fiat']
this.totalBreakdown.selectedTags = this.totalBreakdownTags.map(
this.totalBreakdownTagKey
)
this.totalBreakdown.loading = false
})
.catch(err => {
this.totalBreakdown.loading = false
LNbits.utils.notifyApiError(err)
})
},
totalBreakdownTagLabel(tag) {
return tag || 'No tag'
},
totalBreakdownTagKey(tag) {
return tag || '__untagged__'
},
totalBreakdownTagCount(tag) {
return this.totalBreakdown.rows
.filter(row => (row.tag || null) === tag)
.reduce((total, row) => total + row.payments_count, 0)
},
totalBreakdownTagMsat(tag) {
return this.totalBreakdown.rows
.filter(row => (row.tag || null) === tag)
.reduce((total, row) => total + row.total, 0)
},
formatTotalBreakdownMsat(msat) {
return this.utils.formatBalance(
Math.round(msat / 1000),
this.g.denomination
)
},
handleSendLnurl(lnurl) {
this.parse.data.request = lnurl
this.parse.show = true
@@ -337,12 +223,6 @@ window.PageWallet = {
if (data.tag === 'payRequest') {
this.parse.lnurlpay = Object.freeze(data)
this.parse.data.amount = data.minSendable / 1000
this.receive.units = [
'sats',
...(this.g.allowedCurrencies.length > 0
? this.g.allowedCurrencies
: this.g.currencies)
]
} else if (data.tag === 'login') {
this.parse.lnurlauth = Object.freeze(data)
} else if (data.tag === 'withdrawRequest') {
+9 -19
View File
@@ -1,13 +1,17 @@
window._lnbitsUtils = {
url_for(url) {
const _url = new URL(url, window.location.origin)
_url.searchParams.set('v', window.g.settings.cacheKey)
urlFor(url, noCache = false) {
if (url.startsWith('http://') || url.startsWith('https://')) {
return url
}
const rootPath = ROOT_PATH.replace(/\/+$/, '')
const _url = new URL(rootPath + url, window.location.origin)
if (!noCache) _url.searchParams.set('v', window.g.settings.cacheKey)
return _url.toString()
},
loadScript(src) {
return new Promise((resolve, reject) => {
const script = document.createElement('script')
script.src = this.url_for(src)
script.src = this.urlFor(src)
script.onload = () => {
resolve()
}
@@ -18,7 +22,7 @@ window._lnbitsUtils = {
})
},
async loadTemplate(url) {
return fetch(this.url_for(url))
return fetch(this.urlFor(url))
.then(response => {
if (!response.ok) {
throw new Error(`Failed to load template from ${url}`)
@@ -323,20 +327,6 @@ window._lnbitsUtils = {
converter.setOption('simpleLineBreaks', true)
return converter.makeHtml(text)
},
_extI18nDirs: new Set(),
_extI18nLoaded: {},
loadExtI18n(dir, locale) {
this._extI18nDirs.add(dir)
const loaded = (this._extI18nLoaded[dir] ??= {})
if (loaded[locale]) return loaded[locale]
loaded[locale] = this.loadScript(`${dir}/${locale}.js`).catch(() => {
if (locale !== 'en') {
loaded['en'] ??= this.loadScript(`${dir}/en.js`).catch(() => {})
return loaded['en']
}
})
return loaded[locale]
},
async decryptLnurlPayAES(success_action, preimage) {
let keyb = new Uint8Array(
preimage.match(/[\da-f]{2}/gi).map(h => parseInt(h, 16))
+3 -6
View File
@@ -6,10 +6,7 @@ from collections.abc import Callable, Coroutine
from loguru import logger
from lnbits.core.models import Payment
from lnbits.core.services.payments import (
get_standalone_payment,
update_invoice_from_paid_invoices_stream,
)
from lnbits.core.services.payments import update_invoice_callback
from lnbits.settings import settings
from lnbits.wallets import get_funding_source
@@ -115,7 +112,7 @@ async def internal_invoice_listener() -> None:
while settings.lnbits_running:
checking_id = await internal_invoice_queue.get()
logger.info(f"got an internal payment notification {checking_id}")
payment = await get_standalone_payment(checking_id, incoming=True)
payment = await update_invoice_callback(checking_id)
if payment:
logger.success(f"internal invoice {checking_id} settled")
await invoice_callback_dispatcher(payment)
@@ -131,7 +128,7 @@ async def invoice_listener() -> None:
funding_source = get_funding_source()
async for checking_id in funding_source.paid_invoices_stream():
logger.info(f"got a payment notification {checking_id}")
payment = await update_invoice_from_paid_invoices_stream(checking_id)
payment = await update_invoice_callback(checking_id)
if payment:
logger.success(f"fundingsource invoice {checking_id} settled")
await invoice_callback_dispatcher(payment)
+9 -3
View File
@@ -23,6 +23,7 @@
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, shrink-to-fit=no"
/>
<link rel="icon" type="image/x-icon" href="{{ ROOT_PATH }}favicon.ico" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<link
@@ -32,6 +33,13 @@
{% if web_manifest %}
<link async="async" rel="manifest" href="{{ web_manifest }}" />
{% endif %} {% block head_scripts %}{% endblock %}
<script type="text/javascript">
const ROOT_PATH = '{{ ROOT_PATH }}'
const RENDERED_ROUTE = '{{ normalize_path(request.path) }}'.replace(
ROOT_PATH.replace(/\/+$/, '') || '/',
''
)
</script>
</head>
<body data-theme="bitcoin">
@@ -52,9 +60,7 @@
v-if="g.user && !g.isPublicPage"
></lnbits-header-wallets>
<!-- block page content from static extensions -->
<div
v-if="$route.path.startsWith('{{ normalize_path(request.path) }}')"
>
<div v-if="$route.path.startsWith(RENDERED_ROUTE)">
{% block page %}{% endblock %}
</div>
<!-- vue router-view -->
@@ -26,7 +26,7 @@
@click="g.visibleDrawer = !g.visibleDrawer"
></q-btn>
<q-toolbar-title>
<q-btn flat no-caps dense class="q-mr-sm" size="lg" type="a" href="/">
<q-btn flat no-caps dense class="q-mr-sm" size="lg" type="a" :href="utils.urlFor('/', true)">
<q-avatar v-if="g.settings.customLogo" height="30px">
<img alt="Logo" :src="g.settings.customLogo" />
</q-avatar>
@@ -7,7 +7,9 @@
<a :href="logo.url" target="_blank" rel="noopener noreferrer">
<q-img
contain
:src="$q.dark.isActive ? logo.darkSrc : logo.lightSrc"
:src="
utils.urlFor($q.dark.isActive ? logo.darkSrc : logo.lightSrc)
"
></q-img>
</a>
</div>
@@ -18,7 +20,9 @@
<a :href="logo.url" target="_blank" rel="noopener noreferrer">
<q-img
contain
:src="$q.dark.isActive ? logo.darkSrc : logo.lightSrc"
:src="
utils.urlFor($q.dark.isActive ? logo.darkSrc : logo.lightSrc)
"
></q-img>
</a>
</div>
@@ -20,11 +20,14 @@
clickable
:active="$route.path.startsWith('/' + extension.code)"
tag="a"
:to="'/' + extension.code + '/'"
:to="`/${extension.code}/`"
>
<q-item-section side>
<q-avatar size="md">
<q-img :src="extension.tile" style="max-width: 20px"></q-img>
<q-img
:src="utils.urlFor(extension.tile)"
style="max-width: 20px"
></q-img>
</q-avatar>
</q-item-section>
<q-item-section>
+6 -2
View File
@@ -285,7 +285,9 @@
>
<q-avatar size="32px" class="q-mr-md">
<q-img
:src="'{{ static_url_for('static', 'images/google-logo.png') }}'"
:src="
utils.urlFor('/static/images/google-logo.png')
"
></q-img>
</q-avatar>
<div>Google</div>
@@ -306,7 +308,9 @@
>
<q-avatar size="32px" class="q-mr-md">
<q-img
:src="'{{ static_url_for('static', 'images/github-logo.png') }}'"
:src="
utils.urlFor('/static/images/github-logo.png')
"
></q-img>
</q-avatar>
<div>GitHub</div>
+1 -23
View File
@@ -7,29 +7,7 @@
<q-tabs v-model="tab" active-color="primary" align="left">
<q-tab name="installed" :label="$t('installed')"></q-tab>
<q-tab name="all" :label="$t('all')"></q-tab>
<q-tab
v-show="$q.screen.gt.xs"
name="featured"
:label="$t('featured')"
></q-tab>
<q-btn-dropdown auto-close stretch flat :label="$t('categories')">
<q-list>
<q-item
clickable
v-close-popup
v-for="category in categories"
@click="tab = category"
:key="category"
>
<q-item-section>
<q-item-label
class="text-capitalize"
v-text="category"
></q-item-label>
</q-item-section>
</q-item>
</q-list>
</q-btn-dropdown>
<q-tab name="featured" :label="$t('featured')"></q-tab>
<i
v-if="!g.user.admin && tab != 'installed'"
v-text="$t('only_admins_can_install')"
+8 -121
View File
@@ -28,13 +28,7 @@
<div class="col-7">
<div class="row">
<div class="col-auto">
<div
class="text-h3 q-my-none full-width cursor-pointer"
role="button"
tabindex="0"
@click="showWalletTotalBreakdown"
@keyup.enter="showWalletTotalBreakdown"
>
<div class="text-h3 q-my-none full-width">
<strong
v-text="
utils.formatBalance(g.wallet.sat, g.denomination)
@@ -83,11 +77,7 @@
<div class="col-auto">
<div
v-if="g.fiatTracking"
class="text-h3 q-my-none text-no-wrap cursor-pointer"
role="button"
tabindex="0"
@click="showWalletTotalBreakdown"
@keyup.enter="showWalletTotalBreakdown"
class="text-h3 q-my-none text-no-wrap"
>
<strong v-text="formattedFiatAmount"></strong>
</div>
@@ -226,8 +216,12 @@
<q-card-section class="text-subtitle1">
<span v-text="g.settings.adSpaceTitle"></span>
<a :href="ad[0]" class="lnbits-ad" v-for="ad in g.settings.adSpace">
<q-img class="q-mb-xs" v-if="$q.dark.isActive" :src="ad[1]"></q-img>
<q-img class="q-mb-xs" v-else :src="ad[2]"></q-img>
<q-img
class="q-mb-xs"
v-if="$q.dark.isActive"
:src="utils.urlFor(ad[1])"
></q-img>
<q-img class="q-mb-xs" v-else :src="utils.urlFor(ad[2])"></q-img>
</a>
</q-card-section>
</q-card>
@@ -238,113 +232,6 @@
</div>
</div>
<q-dialog v-model="totalBreakdown.show" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-card-section>
<div class="row items-start q-mb-md">
<div class="col">
<div
class="text-h4 text-bold"
v-text="primaryTotalBreakdownValue"
></div>
<div
v-if="secondaryTotalBreakdownValue"
class="text-h6 text-italic"
style="opacity: 0.75"
v-text="secondaryTotalBreakdownValue"
></div>
<div
class="text-caption text-grey-5"
v-text="selectedTotalBreakdownCount + ' payments'"
></div>
</div>
<q-btn
flat
dense
round
color="grey"
icon="refresh"
:loading="totalBreakdown.loading"
@click="fetchTotalBreakdown"
>
<q-tooltip>Refresh</q-tooltip>
</q-btn>
</div>
<q-inner-loading :showing="totalBreakdown.loading"></q-inner-loading>
<div v-if="hasFiatTotalBreakdown" class="q-mb-md">
<q-checkbox
v-model="totalBreakdown.selectedTypes"
val="bitcoin"
label="Bitcoin"
></q-checkbox>
<q-checkbox
v-model="totalBreakdown.selectedTypes"
val="fiat"
label="Fiat"
></q-checkbox>
</div>
<q-separator v-if="hasFiatTotalBreakdown" class="q-mb-md"></q-separator>
<div style="max-height: min(52vh, 420px); overflow-y: auto">
<q-list dense>
<q-item
v-for="tag in totalBreakdownTags"
:key="totalBreakdownTagKey(tag)"
tag="label"
v-ripple
>
<q-item-section side>
<q-checkbox
v-model="totalBreakdown.selectedTags"
:val="totalBreakdownTagKey(tag)"
></q-checkbox>
</q-item-section>
<q-item-section>
<q-item-label>
<q-badge
v-if="tag"
color="yellow"
text-color="black"
v-text="'#' + tag"
></q-badge>
<span v-else v-text="totalBreakdownTagLabel(tag)"></span>
</q-item-label>
<q-item-label
caption
v-text="totalBreakdownTagCount(tag) + ' payments'"
></q-item-label>
</q-item-section>
<q-item-section side>
<span
v-text="formatTotalBreakdownMsat(totalBreakdownTagMsat(tag))"
></span>
</q-item-section>
</q-item>
</q-list>
</div>
<div v-if="!totalBreakdown.loading && !totalBreakdown.rows.length">
<q-banner class="bg-transparent text-grey-5">
No completed payments.
</q-banner>
</div>
<div class="row q-mt-md">
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
:label="$t('close')"
></q-btn>
</div>
</q-card-section>
</q-card>
</q-dialog>
<q-dialog v-model="receive.show" position="top" @hide="onReceiveDialogHide">
<q-card
v-if="!receive.paymentReq"
-439
View File
@@ -1,439 +0,0 @@
"""
Electrum protocol client (https://github.com/spesmilo/electrum-protocol).
JSON-RPC 2.0 over TCP / SSL (newline-delimited), with request/response
correlation, subscription dispatch, and automatic keepalive pings.
server.version is sent automatically on connect as required by the spec.
"""
import asyncio
import hashlib
import itertools
import json
import ssl
from collections.abc import Callable
from typing import Any
from urllib.parse import urlparse
from loguru import logger
from pydantic import BaseModel
class ElectrumError(Exception):
pass
def scripthash_from_scriptpubkey(scriptpubkey: bytes) -> str:
"""Electrum script hash: SHA-256 of scriptPubKey, byte-reversed to hex."""
return hashlib.sha256(scriptpubkey).digest()[::-1].hex()
# ---------------------------------------------------------------------------
# Response models
# ---------------------------------------------------------------------------
class Balance(BaseModel):
confirmed: int
unconfirmed: int
class HistoryEntry(BaseModel):
tx_hash: str
height: int
fee: int | None = None # present for mempool entries
class MempoolEntry(BaseModel):
tx_hash: str
height: int
fee: int
class UTXO(BaseModel):
tx_hash: str
tx_pos: int
height: int
value: int # satoshis
class BlockHeader(BaseModel):
height: int
hex: str
class BlockHeaderProof(BaseModel):
"""Returned by get_block_header when cp_height > 0."""
branch: list[str]
header: str
root: str
class BlockHeaders(BaseModel):
count: int
hex: str
max: int
class MerkleProof(BaseModel):
block_height: int
merkle: list[str]
pos: int
class TxIdWithMerkle(BaseModel):
tx_hash: str
merkle: list[str]
class FeeHistogramEntry(BaseModel):
fee_rate: float
vsize: float
class ServerFeatures(BaseModel):
class Config:
extra = "allow"
genesis_hash: str = ""
protocol_max: str = ""
protocol_min: str = ""
server_version: str = ""
pruning: int | None = None
hash_function: str = "sha256d"
hosts: dict[str, Any] = {}
# ---------------------------------------------------------------------------
# Client
# ---------------------------------------------------------------------------
class ElectrumClient:
"""
Async Electrum protocol client over plain TCP or SSL.
Messages are newline-terminated JSON-RPC 2.0, as required by the spec.
Handles request/response correlation by id, routes push notifications to
registered callbacks, and sends periodic pings to keep the connection alive.
Usage::
# Plain TCP
async with ElectrumClient("tcp://blockstream.info:110") as client:
height = await client.get_height()
# SSL
async with ElectrumClient("ssl://electrum.blockstream.info:50002") as c:
height = await c.get_height()
"""
def __init__(
self,
url: str,
client_name: str = "lnbits",
protocol_version: str = "1.4",
ping_interval: float = 60.0,
) -> None:
parsed = urlparse(url)
self.host = parsed.hostname or ""
self.port = parsed.port or (
50002 if parsed.scheme in ("ssl", "https") else 50001
)
self.use_ssl = parsed.scheme in ("ssl", "https")
self.client_name = client_name
self.protocol_version = protocol_version
self.ping_interval = ping_interval
self._counter = itertools.count(1)
self._pending: dict[int, asyncio.Future[Any]] = {}
self._subscriptions: dict[str, list[Callable[[list[Any]], Any]]] = {}
self._recv_task: asyncio.Task[None] | None = None
self._ping_task: asyncio.Task[None] | None = None
self._reader: asyncio.StreamReader | None = None
self._writer: asyncio.StreamWriter | None = None
self.server_version: str = ""
self.negotiated_protocol: str = ""
async def connect(self, timeout: float = 10.0) -> None:
ssl_ctx: ssl.SSLContext | None = None
if self.use_ssl:
ssl_ctx = ssl.create_default_context()
self._reader, self._writer = await asyncio.wait_for(
asyncio.open_connection(
self.host, self.port, ssl=ssl_ctx, limit=4 * 1024 * 1024
),
timeout=timeout,
)
self._recv_task = asyncio.create_task(self._recv_loop())
result = await self._call(
"server.version", [self.client_name, self.protocol_version], timeout=timeout
)
self.server_version, self.negotiated_protocol = result[0], result[1]
logger.debug(
f"Electrum connected: server={self.server_version}"
f" protocol={self.negotiated_protocol}"
)
if self.ping_interval > 0:
self._ping_task = asyncio.create_task(self._ping_loop())
async def close(self) -> None:
for task in (self._ping_task, self._recv_task):
if task:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
except Exception:
logger.debug("Electrum: error while cancelling task")
self._ping_task = None
self._recv_task = None
if self._writer:
self._writer.close()
try:
await asyncio.wait_for(self._writer.wait_closed(), timeout=5.0)
except Exception:
logger.debug("Electrum: error while closing writer")
self._reader = None
self._writer = None
async def __aenter__(self) -> "ElectrumClient":
try:
await self.connect()
except BaseException:
await self.close()
raise
return self
async def __aexit__(self, *_: Any) -> None:
await self.close()
# ---- internal plumbing ----
async def _call(
self,
method: str,
params: list[Any] | dict[str, Any] | None = None,
timeout: float = 30.0,
) -> Any:
if not self._writer:
raise ElectrumError("Not connected")
req_id = next(self._counter)
fut: asyncio.Future[Any] = asyncio.get_running_loop().create_future()
self._pending[req_id] = fut
self._writer.write(
json.dumps(
{
"jsonrpc": "2.0",
"id": req_id,
"method": method,
"params": params if params is not None else [],
}
).encode()
+ b"\n"
)
await self._writer.drain()
try:
return await asyncio.wait_for(asyncio.shield(fut), timeout=timeout)
except asyncio.TimeoutError as exc:
self._pending.pop(req_id, None)
raise ElectrumError(f"Timeout waiting for response to {method!r}") from exc
def _dispatch(self, msg: dict[str, Any]) -> None:
msg_id = msg.get("id")
if msg_id is not None:
fut = self._pending.pop(msg_id, None)
if fut and not fut.done():
err = msg.get("error")
if err:
fut.set_exception(ElectrumError(err))
else:
fut.set_result(msg.get("result"))
else:
method = msg.get("method", "")
params = msg.get("params", [])
for cb in list(self._subscriptions.get(method, [])):
try:
result = cb(params)
if asyncio.iscoroutine(result):
self._bg_tasks.add(asyncio.create_task(result))
except Exception:
logger.exception(f"Electrum: callback error for {method!r}")
async def _recv_loop(self) -> None:
assert self._reader
self._bg_tasks: set[asyncio.Task[Any]] = set()
buf = b""
try:
while True:
chunk = await self._reader.read(65536)
if not chunk:
break
buf += chunk
while b"\n" in buf:
line, buf = buf.split(b"\n", 1)
if not line.strip():
continue
try:
msg: dict[str, Any] = json.loads(line)
except json.JSONDecodeError:
logger.warning(f"Electrum: invalid JSON: {line!r}")
continue
self._dispatch(msg)
except asyncio.CancelledError:
pass
except Exception:
logger.exception("Electrum: recv loop error")
finally:
for fut in self._pending.values():
if not fut.done():
fut.set_exception(ElectrumError("Connection closed"))
self._pending.clear()
async def _ping_loop(self) -> None:
try:
while True:
await asyncio.sleep(self.ping_interval)
await self._call("server.ping")
except asyncio.CancelledError:
pass
except Exception:
logger.exception("Electrum: ping loop error")
# ---- subscription management ----
def on(self, method: str, callback: Callable[[list[Any]], Any]) -> None:
"""Register a notification callback for a subscription method."""
self._subscriptions.setdefault(method, []).append(callback)
def off(self, method: str, callback: Callable[[list[Any]], Any]) -> None:
"""Remove a previously registered notification callback."""
cbs = self._subscriptions.get(method)
if cbs and callback in cbs:
cbs.remove(callback)
# ---- server methods ----
async def server_ping(self) -> None:
await self._call("server.ping")
async def server_banner(self) -> str:
return await self._call("server.banner")
async def server_features(self) -> ServerFeatures:
data = await self._call("server.features")
return ServerFeatures.parse_obj(data)
async def server_peers(self) -> list[Any]:
return await self._call("server.peers.subscribe")
# ---- scripthash methods ----
async def get_balance(self, scripthash: str) -> Balance:
data = await self._call("blockchain.scripthash.get_balance", [scripthash])
return Balance.parse_obj(data)
async def get_history(self, scripthash: str) -> list[HistoryEntry]:
data = await self._call("blockchain.scripthash.get_history", [scripthash])
return [HistoryEntry.parse_obj(e) for e in data]
async def get_mempool(self, scripthash: str) -> list[MempoolEntry]:
data = await self._call("blockchain.scripthash.get_mempool", [scripthash])
return [MempoolEntry.parse_obj(e) for e in data]
async def listunspent(self, scripthash: str) -> list[UTXO]:
data = await self._call("blockchain.scripthash.listunspent", [scripthash])
return [UTXO.parse_obj(e) for e in data]
async def subscribe_scripthash(
self,
scripthash: str,
callback: Callable[[list[Any]], Any] | None = None,
) -> str | None:
"""Subscribe to status changes; returns current status hash or None."""
if callback:
self.on("blockchain.scripthash.subscribe", callback)
return await self._call("blockchain.scripthash.subscribe", [scripthash])
async def unsubscribe_scripthash(
self,
scripthash: str,
callback: Callable[[list[Any]], Any] | None = None,
) -> bool:
if callback:
self.off("blockchain.scripthash.subscribe", callback)
return await self._call("blockchain.scripthash.unsubscribe", [scripthash])
async def subscribe_headers(
self,
callback: Callable[[list[Any]], Any] | None = None,
) -> BlockHeader:
"""Subscribe to new block headers; returns current tip."""
if callback:
self.on("blockchain.headers.subscribe", callback)
data = await self._call("blockchain.headers.subscribe")
return BlockHeader.parse_obj(data)
# ---- transaction methods ----
async def broadcast(self, raw_tx: str) -> str:
"""Broadcast a raw transaction hex; returns txid on success."""
return await self._call("blockchain.transaction.broadcast", [raw_tx])
async def get_transaction(
self, txid: str, verbose: bool = False
) -> str | dict[str, Any]:
return await self._call("blockchain.transaction.get", [txid, verbose])
async def get_merkle(self, txid: str, height: int) -> MerkleProof:
data = await self._call("blockchain.transaction.get_merkle", [txid, height])
return MerkleProof.parse_obj(data)
async def get_tx_id_from_pos(
self, height: int, tx_pos: int, merkle: bool = False
) -> str | TxIdWithMerkle:
data = await self._call(
"blockchain.transaction.id_from_pos", [height, tx_pos, merkle]
)
if isinstance(data, dict):
return TxIdWithMerkle.parse_obj(data)
return data
# ---- block methods ----
async def get_tip(self) -> BlockHeader:
"""Returns current chain tip."""
data = await self._call("blockchain.headers.subscribe")
return BlockHeader.parse_obj(data)
async def get_height(self) -> int:
"""Returns the current best block height."""
return (await self.get_tip()).height
async def get_block_header(
self, height: int, cp_height: int = 0
) -> str | BlockHeaderProof:
data = await self._call("blockchain.block.header", [height, cp_height])
if isinstance(data, dict):
return BlockHeaderProof.parse_obj(data)
return data
async def get_block_headers(
self, start_height: int, count: int, cp_height: int = 0
) -> BlockHeaders:
data = await self._call(
"blockchain.block.headers", [start_height, count, cp_height]
)
return BlockHeaders.parse_obj(data)
# ---- fee methods ----
async def estimate_fee(self, num_blocks: int) -> float:
"""Returns estimated fee rate in BTC/kB for confirmation within num_blocks."""
return await self._call("blockchain.estimatefee", [num_blocks])
async def fee_histogram(self) -> list[FeeHistogramEntry]:
"""Returns mempool fee histogram as FeeHistogramEntry(fee_rate, vsize) list."""
data = await self._call("mempool.get_fee_histogram")
return [FeeHistogramEntry(fee_rate=r[0], vsize=r[1]) for r in data]
+40 -41
View File
@@ -3,7 +3,6 @@ import base64
import hashlib
import json
from collections.abc import AsyncGenerator
from typing import Any
import httpx
from loguru import logger
@@ -124,8 +123,8 @@ class LndRestWallet(Wallet):
hashlib.sha256(unhashed_description).digest()
).decode("ascii")
preimage, payment_hash = random_secret_and_hash()
_data["r_hash"] = base64.b64encode(bytes.fromhex(payment_hash)).decode()
preimage, _payment_hash = random_secret_and_hash()
_data["r_hash"] = base64.b64encode(bytes.fromhex(_payment_hash)).decode()
_data["r_preimage"] = base64.b64encode(bytes.fromhex(preimage)).decode()
try:
@@ -133,7 +132,34 @@ class LndRestWallet(Wallet):
r.raise_for_status()
data = r.json()
return self._parse_create_invoice_response(r, data, preimage)
if len(data) == 0:
return InvoiceResponse(ok=False, error_message="no data")
if "error" in data:
return InvoiceResponse(
ok=False, error_message=f"""Server error: '{data["error"]}'"""
)
if r.is_error:
return InvoiceResponse(
ok=False, error_message=f"Server error: '{r.text}'"
)
if "payment_request" not in data or "r_hash" not in data:
return InvoiceResponse(
ok=False, error_message="Server error: 'missing required fields'"
)
payment_request = data["payment_request"]
payment_hash = base64.b64decode(data["r_hash"]).hex()
checking_id = payment_hash
return InvoiceResponse(
ok=True,
checking_id=checking_id,
payment_request=payment_request,
preimage=preimage,
)
except json.JSONDecodeError:
return InvoiceResponse(
ok=False, error_message="Server error: 'invalid json response'"
@@ -216,13 +242,12 @@ class LndRestWallet(Wallet):
logger.warning(f"Error getting invoice status: {e}")
return PaymentPendingStatus()
if r.is_error or data.get("state") is None:
if r.is_error or data.get("settled") is None:
# this must also work when checking_id is not a hex recognizable by lnd
# it will return an error and no "state" attribute on the object
logger.warning(f"Error checking invoice from LND REST API: {r.text}")
# it will return an error and no "settled" attribute on the object
return PaymentPendingStatus()
if data.get("state") == "SETTLED":
if data.get("settled") is True:
return PaymentSuccessStatus()
if data.get("state") == "CANCELED":
@@ -239,11 +264,10 @@ class LndRestWallet(Wallet):
"ascii"
)
except ValueError:
logger.warning("Invalid checking_id format, must be hex: {checking_id}")
return PaymentPendingStatus()
url = f"/v2/router/track/{checking_id}"
async with self.client.stream("GET", url, timeout=30) as r:
async with self.client.stream("GET", url, timeout=None) as r:
async for json_line in r.aiter_lines():
try:
line = json.loads(json_line)
@@ -274,7 +298,7 @@ class LndRestWallet(Wallet):
return PaymentFailedStatus()
elif status == "IN_FLIGHT":
logger.info(f"LNDRest Payment in flight: {checking_id}")
continue
return PaymentPendingStatus()
logger.info(f"LNDRest Payment non-existent: {checking_id}")
return PaymentPendingStatus()
@@ -287,12 +311,13 @@ class LndRestWallet(Wallet):
async for line in r.aiter_lines():
try:
inv = json.loads(line)["result"]
if not inv.get("state") == "SETTLED":
if not inv["settled"]:
continue
payment_hash = base64.b64decode(inv.get("r_hash")).hex()
except Exception as exc:
logger.debug(exc)
continue
payment_hash = base64.b64decode(inv["r_hash"]).hex()
yield payment_hash
except Exception as exc:
logger.warning(
@@ -338,6 +363,8 @@ class LndRestWallet(Wallet):
return InvoiceResponse(ok=False, error_message=str(exc))
payment_request = data["payment_request"]
payment_hash = base64.b64encode(bytes.fromhex(payment_hash)).decode("ascii")
return InvoiceResponse(
ok=True, checking_id=payment_hash, payment_request=payment_request
)
@@ -372,31 +399,3 @@ class LndRestWallet(Wallet):
except Exception as exc:
logger.warning(exc)
return InvoiceResponse(ok=False, error_message=str(exc))
def _parse_create_invoice_response(
self, r: Any, data: dict, preimage: str
) -> InvoiceResponse:
if not data:
return InvoiceResponse(ok=False, error_message="no data")
if "error" in data:
return InvoiceResponse(
ok=False, error_message=f"Server error: '{data['error']}'"
)
if r.is_error:
return InvoiceResponse(ok=False, error_message=f"Server error: '{r.text}'")
if "payment_request" not in data or "r_hash" not in data:
return InvoiceResponse(
ok=False, error_message="Server error: 'missing required fields'"
)
try:
payment_hash = base64.b64decode(data["r_hash"]).hex()
except Exception:
return InvoiceResponse(
ok=False, error_message=f"Unable to b64decode to {data['r_hash']}."
)
return InvoiceResponse(
ok=True,
checking_id=payment_hash,
payment_request=data["payment_request"],
preimage=preimage,
)
+4 -5
View File
@@ -117,12 +117,11 @@ class PhoenixdWallet(Wallet):
# PhoenixD description limited to 128 characters
if description_hash:
data["descriptionHash"] = description_hash.hex()
elif unhashed_description:
data["descriptionHash"] = hashlib.sha256(
unhashed_description
).hexdigest()
else:
desc = memo or ""
desc = memo
if desc is None and unhashed_description:
desc = unhashed_description.decode()
desc = desc or ""
if len(desc) > 128:
data["descriptionHash"] = hashlib.sha256(desc.encode()).hexdigest()
else:
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "lnbits"
version = "1.5.5-rc3"
version = "1.5.5-rc2"
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" }]
+3 -73
View File
@@ -6,16 +6,11 @@ import pytest
from fastapi import HTTPException
from pydantic import ValidationError
from lnbits.core.crud.payments import (
create_payment,
get_payment,
get_payments,
update_payment,
)
from lnbits.core.crud.payments import create_payment, get_payment, get_payments
from lnbits.core.models import Account, CreateInvoice, PaymentFilters, PaymentState
from lnbits.core.models.payments import CancelInvoice, CreatePayment, SettleInvoice
from lnbits.core.models.users import AccountId
from lnbits.core.models.wallets import BaseWalletTypeInfo, KeyType, WalletTypeInfo
from lnbits.core.models.wallets import KeyType, WalletTypeInfo
from lnbits.core.services.payments import create_wallet_invoice
from lnbits.core.services.users import create_user_account
from lnbits.core.views.payment_api import (
@@ -25,7 +20,6 @@ from lnbits.core.views.payment_api import (
api_payments_daily_stats,
api_payments_fee_reserve,
api_payments_settle,
api_payments_total_breakdown,
api_payments_wallets_stats,
)
from lnbits.db import Filter, Filters
@@ -152,61 +146,6 @@ async def test_payment_external_id_is_stored_and_validated():
CreateInvoice(out=False, amount=21, external_id="provider payment 123")
@pytest.mark.anyio
async def test_payment_api_total_breakdown_groups_wallet_tags_and_fiat():
first_user = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
second_user = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
first_wallet = first_user.wallets[0]
second_wallet = second_user.wallets[0]
await _create_payment(first_wallet.id, amount_msat=2_000, tag="coffee")
await _create_payment(
first_wallet.id,
amount_msat=4_000,
tag="coffee",
fiat_provider="stripe",
extra={"fiat_payment_request": "https://stripe.test/session"},
)
await _create_payment(first_wallet.id, amount_msat=-1_000)
await _create_payment(second_wallet.id, amount_msat=8_000, tag="books")
breakdown = await api_payments_total_breakdown(
BaseWalletTypeInfo(key_type=KeyType.invoice, wallet=first_wallet)
)
assert any(
item.tag == "coffee"
and item.is_fiat is False
and item.total == 2_000
and item.payments_count == 1
for item in breakdown
)
assert any(
item.tag == "coffee"
and item.is_fiat is True
and item.total == 4_000
and item.payments_count == 1
for item in breakdown
)
assert any(
item.tag is None and item.is_fiat is False and item.total == -1_000
for item in breakdown
)
assert all(item.tag != "books" for item in breakdown)
@pytest.mark.anyio
async def test_payment_api_fee_reserve_and_hold_invoice_actions(mocker):
user = await create_user_account(
@@ -444,13 +383,8 @@ async def _create_payment(
status: PaymentState = PaymentState.SUCCESS,
payment_hash: str | None = None,
tag: str | None = None,
fiat_provider: str | None = None,
extra: dict | None = None,
) -> str:
checking_id = f"checking_{uuid4().hex[:8]}"
payment_extra = extra or {}
if tag:
payment_extra["tag"] = tag
await create_payment(
checking_id=checking_id,
data=CreatePayment(
@@ -459,12 +393,8 @@ async def _create_payment(
bolt11=f"bolt11_{checking_id}",
amount_msat=amount_msat,
memo=f"payment_{checking_id}",
extra=payment_extra,
extra={"tag": tag} if tag else {},
),
status=status,
)
if fiat_provider:
payment = await get_payment(checking_id)
payment.fiat_provider = fiat_provider
await update_payment(payment)
return checking_id
+2
View File
@@ -26,6 +26,8 @@ docker_bitcoin_cli = [
"exec",
"lnbits-bitcoind-1",
"bitcoin-cli",
"-rpcuser=lnbits",
"-rpcpassword=lnbits",
"-regtest",
]
-184
View File
@@ -1,184 +0,0 @@
"""
Electrum client integration tests against the regtest electrs container.
Requires the regtest docker-compose stack (docker/regtest/docker-compose.yml).
electrs is exposed on localhost:19001 (plain TCP) and localhost:3002 (HTTP).
"""
import asyncio
import httpx
import pytest
from loguru import logger
from lnbits.utils.electrum import ElectrumClient, scripthash_from_scriptpubkey
from .helpers import docker_bitcoin_cli, run_cmd, run_cmd_json
ELECTRS_HOST = "localhost"
ELECTRS_PORT = 19001
ELECTRS_HTTP = "http://localhost:3002"
def bitcoin_height() -> int:
return run_cmd_json([*docker_bitcoin_cli, "getblockchaininfo"])["blocks"]
def mine_blocks(n: int = 1) -> int:
"""Mine n blocks and return the new chain height."""
run_cmd([*docker_bitcoin_cli, "-generate", str(n)])
return bitcoin_height()
def new_address() -> str:
return run_cmd([*docker_bitcoin_cli, "getnewaddress", "bech32"])
def get_scriptpubkey(address: str) -> bytes:
info = run_cmd_json([*docker_bitcoin_cli, "getaddressinfo", address])
return bytes.fromhex(info["scriptPubKey"])
def send_to_address(address: str, sats: int) -> str:
btc = f"{sats * 1e-8:.8f}"
return run_cmd([*docker_bitcoin_cli, "sendtoaddress", address, btc])
async def wait_for_electrs(height: int, timeout: float = 15.0) -> None:
"""Poll electrs HTTP until it has indexed up to `height`."""
loop = asyncio.get_running_loop()
deadline = loop.time() + timeout
async with httpx.AsyncClient() as http:
while loop.time() < deadline:
try:
r = await http.get(f"{ELECTRS_HTTP}/blocks/tip/height", timeout=2)
if int(r.text) >= height:
return
except Exception:
logger.debug("electrs not ready yet")
await asyncio.sleep(0.25)
raise TimeoutError(f"electrs did not reach height {height} within {timeout}s")
@pytest.fixture(scope="module", autouse=True)
async def wait_after_electrum_tests():
yield
await asyncio.sleep(1)
@pytest.mark.anyio
async def test_connect_and_height():
async with ElectrumClient(f"tcp://{ELECTRS_HOST}:{ELECTRS_PORT}") as client:
before = await client.get_height()
target = mine_blocks(3)
await wait_for_electrs(target)
async with ElectrumClient(f"tcp://{ELECTRS_HOST}:{ELECTRS_PORT}") as client:
after = await client.get_height()
assert isinstance(before, int) and before >= 0
assert after == before + 3
@pytest.mark.anyio
async def test_get_tip():
async with ElectrumClient(f"tcp://{ELECTRS_HOST}:{ELECTRS_PORT}") as client:
tip = await client.get_tip()
assert isinstance(tip.height, int)
assert isinstance(tip.hex, str)
assert len(tip.hex) == 160 # 80-byte serialised header
@pytest.mark.anyio
async def test_server_banner():
async with ElectrumClient(f"tcp://{ELECTRS_HOST}:{ELECTRS_PORT}") as client:
banner = await client.server_banner()
assert isinstance(banner, str)
@pytest.mark.anyio
async def test_balance_after_payment():
address = new_address()
scripthash = scripthash_from_scriptpubkey(get_scriptpubkey(address))
async with ElectrumClient(f"tcp://{ELECTRS_HOST}:{ELECTRS_PORT}") as client:
empty = await client.get_balance(scripthash)
assert empty.confirmed == 0
assert empty.unconfirmed == 0
send_to_address(address, 500_000)
target = mine_blocks(1)
await wait_for_electrs(target)
async with ElectrumClient(f"tcp://{ELECTRS_HOST}:{ELECTRS_PORT}") as client:
confirmed = await client.get_balance(scripthash)
assert confirmed.confirmed == 500_000
assert confirmed.unconfirmed == 0
@pytest.mark.anyio
async def test_history_and_utxos():
address = new_address()
scripthash = scripthash_from_scriptpubkey(get_scriptpubkey(address))
send_to_address(address, 250_000)
target = mine_blocks(1)
await wait_for_electrs(target)
async with ElectrumClient(f"tcp://{ELECTRS_HOST}:{ELECTRS_PORT}") as client:
history = await client.get_history(scripthash)
assert len(history) >= 1
assert history[0].tx_hash
assert history[0].height > 0
utxos = await client.listunspent(scripthash)
assert len(utxos) == 1
assert utxos[0].value == 250_000
raw_tx = await client.get_transaction(utxos[0].tx_hash)
assert isinstance(raw_tx, str) and len(raw_tx) > 0
@pytest.mark.anyio
async def test_subscribe_scripthash_payment():
address = new_address()
scripthash = scripthash_from_scriptpubkey(get_scriptpubkey(address))
received: list = []
event = asyncio.Event()
def on_change(params: list) -> None:
received.append(params)
event.set()
async with ElectrumClient(f"tcp://{ELECTRS_HOST}:{ELECTRS_PORT}") as client:
initial_status = await client.subscribe_scripthash(
scripthash, callback=on_change
)
assert initial_status is None # fresh address has no history
send_to_address(address, 777_000)
target = mine_blocks(1)
await wait_for_electrs(target)
await asyncio.wait_for(event.wait(), timeout=10)
assert len(received) == 1
assert received[0][0] == scripthash # first param is the scripthash
assert received[0][1] is not None # second param is the new status hash
balance = await client.get_balance(scripthash)
assert balance.confirmed == 777_000
@pytest.mark.anyio
async def test_subscribe_headers():
async with ElectrumClient(f"tcp://{ELECTRS_HOST}:{ELECTRS_PORT}") as client:
notifications: list = []
tip = await client.subscribe_headers(callback=lambda p: notifications.append(p))
height_before = tip.height
target = mine_blocks(1)
await wait_for_electrs(target)
assert await client.get_height() == height_before + 1
+11 -97
View File
@@ -8,7 +8,7 @@ from unittest.mock import AsyncMock
import pytest
from pytest_mock.plugin import MockerFixture
from lnbits.core.crud.payments import get_payment, get_payments
from lnbits.core.crud.payments import get_payments
from lnbits.core.crud.users import get_user
from lnbits.core.crud.wallets import create_wallet
from lnbits.core.models.payments import CreateInvoice, Payment, PaymentState
@@ -919,10 +919,8 @@ async def test_revolut_wallet_create_subscription(settings: Settings):
"PLAN_VARIATION_123", 1, payment_options
)
subscription_request_id = payment_options.subscription_request_id
assert response.ok is True
assert response.subscription_request_id == "SUBSCRIPTION123"
assert subscription_request_id is not None
assert response.subscription_request_id is not None
assert (
response.checkout_session_url
== "https://checkout.revolut.com/payment-link/sub_123"
@@ -935,14 +933,16 @@ async def test_revolut_wallet_create_subscription(settings: Settings):
assert payload["plan_variation_id"] == "PLAN_VARIATION_123"
assert payload["customer_id"] == "CUSTOMER123"
assert client.calls[1][1]["timeout"] == 30
assert client.calls[1][1]["headers"]["Idempotency-Key"] == (subscription_request_id)
assert client.calls[1][1]["headers"]["Idempotency-Key"] == (
response.subscription_request_id
)
assert payload["setup_order_redirect_url"] == (
"https://lnbits.example/subscription-success"
)
reference = json.loads(payload["external_reference"])
assert reference["wallet_id"] == "wallet_1"
assert reference["tag"] == "gold"
assert reference["subscription_request_id"] == subscription_request_id
assert reference["subscription_request_id"] == response.subscription_request_id
assert reference["memo"] == "Monthly Gold"
assert reference["extra"]["link"] == "link-1"
assert client.calls[2][0] == "/api/orders/ORDER123"
@@ -1235,55 +1235,13 @@ async def test_revolut_wallet_cancel_subscription(settings: Settings):
settings.revolut_api_version = "2026-04-20"
wallet = RevolutWallet()
client = MockHTTPClient(
[
MockHTTPResponse(
json_data={
"external_reference": json.dumps({"wallet_id": "wallet_1"}),
}
),
MockHTTPResponse(json_data={}),
]
)
client = MockHTTPClient([MockHTTPResponse(json_data={})])
wallet.client = client # type: ignore[assignment]
response = await wallet.cancel_subscription("SUBSCRIPTION123", "wallet_1")
assert response.ok is True
assert client.calls[0][0] == "/api/subscriptions/SUBSCRIPTION123"
assert client.calls[1][0] == "/api/subscriptions/SUBSCRIPTION123/cancel"
@pytest.mark.anyio
async def test_revolut_wallet_cancel_subscription_checks_wallet_id(
settings: Settings,
):
settings.revolut_api_endpoint = "https://sandbox-merchant.revolut.com"
settings.revolut_api_secret_key = "revolut-secret"
settings.revolut_api_version = "2026-04-20"
wallet = RevolutWallet()
client = MockHTTPClient(
[
MockHTTPResponse(
json_data={
"external_reference": json.dumps({"wallet_id": "wallet_2"}),
}
),
]
)
wallet.client = client # type: ignore[assignment]
response = await wallet.cancel_subscription("SUBSCRIPTION123", "wallet_1")
assert response.ok is False
assert response.error_message == "Subscription not found."
assert client.calls == [
(
"/api/subscriptions/SUBSCRIPTION123",
{"timeout": 30},
)
]
assert client.calls[0][0] == "/api/subscriptions/SUBSCRIPTION123/cancel"
@pytest.mark.anyio
@@ -1744,59 +1702,15 @@ async def test_check_fiat_status_handles_internal_states(mocker: MockerFixture):
amount=1000,
fee=0,
bolt11="bolt11",
status=PaymentState.SUCCESS,
status=PaymentState.PENDING,
fiat_provider="stripe",
extra={"fiat_checking_id": "stripe_checking_id"},
)
),
skip_internal_payment_notifications=True,
)
assert queue_put.await_count == 1
@pytest.mark.anyio
async def test_check_fiat_status_persists_successful_payment(
to_wallet: Wallet, settings: Settings, mocker: MockerFixture
):
settings.stripe_enabled = True
settings.stripe_api_secret_key = "mock_sk_test_4eC39HqLyjWDarjtT1zdp7dc"
settings.stripe_limits.service_min_amount_sats = 0
settings.stripe_limits.service_max_amount_sats = 0
settings.stripe_limits.service_fee_wallet_id = None
settings.stripe_limits.service_faucet_wallet_id = None
invoice_data = CreateInvoice(
unit="USD", amount=1.0, memo="Test", fiat_provider="stripe"
)
fiat_mock_response = FiatInvoiceResponse(
ok=True,
checking_id=f"session_paid_{get_random_string(10)}",
payment_request="https://stripe.com/pay/session_paid",
)
mocker.patch(
"lnbits.fiat.StripeWallet.create_invoice",
AsyncMock(return_value=fiat_mock_response),
)
mocker.patch(
"lnbits.utils.exchange_rates.get_fiat_rate_satoshis",
AsyncMock(return_value=1000),
)
payment = await payments.create_fiat_invoice(to_wallet.id, invoice_data)
assert payment.status == PaymentState.PENDING
mocker.patch(
"lnbits.fiat.StripeWallet.get_invoice_status",
AsyncMock(return_value=FiatPaymentStatus(paid=True)),
)
queue_put = mocker.patch("lnbits.tasks.internal_invoice_queue.put", AsyncMock())
status = await check_fiat_status(payment)
assert status.success is True
assert payment.status == PaymentState.SUCCESS
updated_payment = await get_payment(payment.checking_id)
assert updated_payment.status == PaymentState.SUCCESS
queue_put.assert_awaited_once_with(payment.checking_id)
@pytest.mark.anyio
async def test_verify_paypal_webhook_requires_configuration(settings: Settings):
settings.paypal_webhook_id = None
+1 -14
View File
@@ -15,12 +15,7 @@ from lnbits.core.services import create_invoice, create_user_account, pay_invoic
from lnbits.core.services.payments import update_wallet_balance
from lnbits.exceptions import InvoiceError, PaymentError
from lnbits.settings import Settings
from lnbits.tasks import (
create_task,
internal_invoice_listener,
internal_invoice_queue,
wait_for_paid_invoices,
)
from lnbits.tasks import create_task, wait_for_paid_invoices
from lnbits.wallets.base import PaymentResponse
from lnbits.wallets.fake import FakeWallet
@@ -236,15 +231,7 @@ async def test_notification_for_internal_payment(
):
test_name = "test_notification_for_internal_payment"
# Drain stale items left by session-scoped fixtures (e.g. update_wallet_balance)
while not internal_invoice_queue.empty():
try:
internal_invoice_queue.get_nowait()
except asyncio.QueueEmpty:
break
on_paid_mock = mocker.AsyncMock()
create_task(internal_invoice_listener())
create_task(wait_for_paid_invoices(test_name, on_paid_mock)())
payment = await create_invoice(
wallet_id=to_wallet.id,
+1 -41
View File
@@ -39,7 +39,6 @@ from lnbits.core.services.notifications import (
send_nostr_notification,
send_nostr_notifications,
send_notification,
send_notification_in_background,
send_payment_notification,
send_payment_push_notification,
send_push_notification,
@@ -118,7 +117,7 @@ async def test_send_admin_and_user_notification_use_expected_targets(
settings: Settings, mocker: MockerFixture
):
send_mock = mocker.patch(
"lnbits.core.services.notifications.send_notification_in_background",
"lnbits.core.services.notifications.send_notification",
mocker.AsyncMock(),
)
original_chat_id = settings.lnbits_telegram_notifications_chat_id
@@ -160,45 +159,6 @@ async def test_send_admin_and_user_notification_use_expected_targets(
)
@pytest.mark.anyio
async def test_send_notification_in_background_schedules_notification(
mocker: MockerFixture,
):
scheduled = []
def create_task(coro):
scheduled.append(coro)
coro.close()
return mocker.Mock()
create_task_mock = mocker.patch(
"lnbits.core.services.notifications.create_task",
side_effect=create_task,
)
send_mock = mocker.patch(
"lnbits.core.services.notifications.send_notification",
mocker.AsyncMock(),
)
await send_notification_in_background(
"chat-id",
["alice@example.com"],
["admin@example.com"],
"hello",
"settings_update",
)
create_task_mock.assert_called_once()
send_mock.assert_called_once_with(
"chat-id",
["alice@example.com"],
["admin@example.com"],
"hello",
"settings_update",
)
assert len(scheduled) == 1
@pytest.mark.anyio
async def test_send_notification_uses_available_channels_and_swallows_exceptions(
settings: Settings, mocker: MockerFixture
+10 -10
View File
@@ -2073,7 +2073,7 @@
{
"response_type": "json",
"response": {
"state": "SETTLED"
"settled": true
}
}
]
@@ -2155,15 +2155,8 @@
]
},
"lndrest": {
"get_invoice_status_endpoint": [
{
"description": "error status",
"response_type": "json",
"response": {
"state": "CANCELED"
}
}
]
"description": "lndrest.py doesn't handle the 'failed' status for `get_invoice_status`",
"get_invoice_status_endpoint": []
},
"alby": {
"description": "alby.py doesn't handle the 'failed' status for `get_invoice_status`",
@@ -2250,6 +2243,13 @@
"response_type": "json",
"response": {}
},
{
"description": "error status",
"response_type": "json",
"response": {
"seetled": false
}
},
{
"description": "bad json",
"response_type": "data",
Generated
+105 -109
View File
@@ -3,7 +3,7 @@ revision = 3
requires-python = ">=3.10, <3.13"
[options]
exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values.
exclude-newer = "2026-05-27T19:33:23.836682466Z"
exclude-newer-span = "P1W"
[[package]]
@@ -17,7 +17,7 @@ wheels = [
[[package]]
name = "aiohttp"
version = "3.14.1"
version = "3.13.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohappyeyeballs" },
@@ -27,65 +27,61 @@ dependencies = [
{ name = "frozenlist" },
{ name = "multidict" },
{ name = "propcache" },
{ name = "typing-extensions" },
{ name = "yarl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035", size = 7955794, upload-time = "2026-06-07T21:09:35.529Z" }
sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271, upload-time = "2026-03-31T22:01:03.343Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6d/67/58ded4b3f2e10f94972d8928050c85330e249a31dd45a0e5f3c0e9c3fa05/aiohttp-3.14.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:8f6bb621e5863cfe8fe5ff5468002d200ec31f30f1280b259dc505b02595099e", size = 766140, upload-time = "2026-06-07T21:05:37.471Z" },
{ url = "https://files.pythonhosted.org/packages/18/68/4ae5b4e08943f316594bb68da89957d3baf5760588fa09509594bd777e4b/aiohttp-3.14.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4f7215cb3933784f79ed20e5f050e15984f390424339b22375d5a53c933a0491", size = 519430, upload-time = "2026-06-07T21:05:40.751Z" },
{ url = "https://files.pythonhosted.org/packages/cb/c1/316c8f3549dbe5245f92bfd523ec6f32dd4d98cafe21df3f6a19b1184c75/aiohttp-3.14.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d9d4e294455b23a68c9b8f042d0e8e377a265bcb15332753695f6e5b6819e0ce", size = 514406, upload-time = "2026-06-07T21:05:42.111Z" },
{ url = "https://files.pythonhosted.org/packages/5a/ee/fb0ac28684e8d753b83c8a4eebc19a5846912aa0a4daaabb6a9936363840/aiohttp-3.14.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b238af795833d5731d049d82bc84b768ae6f8f97f0495963b3ed9935c5901cc3", size = 1703649, upload-time = "2026-06-07T21:05:43.427Z" },
{ url = "https://files.pythonhosted.org/packages/3b/57/aa2beab673331f111885db8a7b69dfe3ab0e53e446a0ace18ca694b4dc58/aiohttp-3.14.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e4e5e0ae56914ecdbf446493addefc0159053dd53962cef37d7839f37f73d505", size = 1675126, upload-time = "2026-06-07T21:05:44.897Z" },
{ url = "https://files.pythonhosted.org/packages/47/ea/dad128abe365e79be03b16ed464198ac73e0d257e8260c6f7d6f31cbef26/aiohttp-3.14.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:092e4ce3619a7c6dee52a6bdabda973d9b34b66781f840ce93c7e0cec30cf521", size = 1771558, upload-time = "2026-06-07T21:05:46.405Z" },
{ url = "https://files.pythonhosted.org/packages/63/f3/b5b4e10327cb85d34d24232c6b71b64602f190b3ccb238a043ac6b187dac/aiohttp-3.14.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb33777ea21e8b7ecde0e6fc84f598be0a1192eab1a63bc746d75aa75d38e7bd", size = 1856631, upload-time = "2026-06-07T21:05:47.844Z" },
{ url = "https://files.pythonhosted.org/packages/2b/9d/93294c3045775c708ac8310eb3d3622a11d2951345ad590d532d62a1faa4/aiohttp-3.14.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23119f8fd4f5d16902ed459b63b100bcd269628075162bddac56cc7b5273b3fb", size = 1714139, upload-time = "2026-06-07T21:05:49.982Z" },
{ url = "https://files.pythonhosted.org/packages/29/c4/93067c85a0373492ce8e577435203c5947c454af074ac48ed4f3a1b9dd4a/aiohttp-3.14.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:57fc6745a4b7d0f5a9eb4f40a69718be6c0bc1b8368cc9fe89e90118719f4f42", size = 1588321, upload-time = "2026-06-07T21:05:51.431Z" },
{ url = "https://files.pythonhosted.org/packages/c4/39/9ff91aaf02af8b7b8222a987466da539f154c3e01732c22b5f5a20a8ee66/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6fd35beba67c4183b09375c5fff9accb47524191a244a99f95fd4472f5402c2b", size = 1670375, upload-time = "2026-06-07T21:05:53.109Z" },
{ url = "https://files.pythonhosted.org/packages/aa/e4/77452a3676b8d99ac1375f77691d6bf65ea6e9f4b201b82ef77c916dc767/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:672b9d65f42eb877f5c3f234a4547e4e1a226ca8c2eed879bb34670a0ce51192", size = 1690933, upload-time = "2026-06-07T21:05:54.902Z" },
{ url = "https://files.pythonhosted.org/packages/7d/84/b0059a7c7fc05ea23f3bc1596ba91c12f79588b9450564a24cac37536d0a/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:24ba13339fed9251d9b1a1bec8c7ab84c0d1675d79d33501e11f94f8b9a84e05", size = 1740798, upload-time = "2026-06-07T21:05:56.458Z" },
{ url = "https://files.pythonhosted.org/packages/8f/3a/e2a513ecbfc362591caa51a7f7e011b3bfc8938b388ae44cd95560d36999/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:94da27378da0610e341c4d30de29a191672683cc82b8f9556e8f7c7212a020fe", size = 1576412, upload-time = "2026-06-07T21:05:57.953Z" },
{ url = "https://files.pythonhosted.org/packages/a1/10/08f1654f538f93d36dcac66310a06eefce4641cdafca83f9f0a5317be254/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:52cdac9432d8b4a719f35094a818d95adcae0f0b4fe9b9b921909e0c87de9e7d", size = 1750199, upload-time = "2026-06-07T21:05:59.488Z" },
{ url = "https://files.pythonhosted.org/packages/99/e4/d91b70c57d8b8e9611e4a2e52238ca3698d3dc1c2efe25b7a9bf594ac584/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:672ac254412a24d0d0cf00a9e6c238877e4be5e5fa2d188832c1244f45f31966", size = 1699356, upload-time = "2026-06-07T21:06:01.131Z" },
{ url = "https://files.pythonhosted.org/packages/3d/f1/15340176f35ff61b95dbe34020bcf43f9e624a2d7bbac934715ff97d2033/aiohttp-3.14.1-cp310-cp310-win32.whl", hash = "sha256:2fe3607e71acc6ebb0ec8e492a247bf7a291226192dc0084236dfc12478916f6", size = 458939, upload-time = "2026-06-07T21:06:02.86Z" },
{ url = "https://files.pythonhosted.org/packages/c3/c2/a2f1ec5b37f903109e43ae2862268cfe4a67a60c1b2cf43169fcdff5995f/aiohttp-3.14.1-cp310-cp310-win_amd64.whl", hash = "sha256:30099eda75a53c32efb0920e9c33c195314d2cc1c680fbfd30894932ac5f27df", size = 482583, upload-time = "2026-06-07T21:06:04.666Z" },
{ url = "https://files.pythonhosted.org/packages/d0/7a/7b56f6732ef79530afaa72aa335d41b67c8d79b946995f0b11ad72985435/aiohttp-3.14.1-cp310-cp310-win_arm64.whl", hash = "sha256:5a837f49d901f9e368651b676912bff1104ed8c1a83b280bcd7b29adccef5c9c", size = 453470, upload-time = "2026-06-07T21:06:06.322Z" },
{ url = "https://files.pythonhosted.org/packages/26/dd/bf526e6f0a1120dd6f2df2e97bacfe4d358f13d17a0ff5847301a1375a51/aiohttp-3.14.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:aa00140699487bd435fde4342d85c94cb256b7cd3a5b9c3396c67f19922afda2", size = 765225, upload-time = "2026-06-07T21:06:07.957Z" },
{ url = "https://files.pythonhosted.org/packages/8f/e1/a2872aa55495a70f61310d411541c6ee23812d9a884e000c716e1bc3edbf/aiohttp-3.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1c1af67559445498b502030c35c59db59966f47041ca9de5b4e707f86bd10b5f", size = 518743, upload-time = "2026-06-07T21:06:09.749Z" },
{ url = "https://files.pythonhosted.org/packages/5b/e7/c60c7b209e509cc787de3cea0550a518538cfc08003e1c1e14c1c63fff71/aiohttp-3.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d44ec478e713ee7f29b439f7eb8dc2b9d4079e11ae114d2c2ac3d5daf30516c8", size = 514139, upload-time = "2026-06-07T21:06:11.26Z" },
{ url = "https://files.pythonhosted.org/packages/5b/8d/614ace2f579702c9840ab1e1447fd8509e35b0b904f7196418fa2f57b25d/aiohttp-3.14.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d3b1a184a9a8f548a6b73f1e26b96b052193e4b3175ed7342aaf1151a1f00a04", size = 1784088, upload-time = "2026-06-07T21:06:12.887Z" },
{ url = "https://files.pythonhosted.org/packages/49/e0/726e90f99542bf292f81a96a12cc4847deb86f3ccf62c6f4014a201f4d33/aiohttp-3.14.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5f2504bc0322437c9a1ff6d3333ca56c7477b727c995f036b976ae17b98372c8", size = 1737835, upload-time = "2026-06-07T21:06:14.564Z" },
{ url = "https://files.pythonhosted.org/packages/0b/4b/d176d5c4db9d33dacf0543102ea59503bc1d528af4cfd0b719949ca49389/aiohttp-3.14.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73f05ea02013e02512c3bf42714f1208c57168c779cc6fe23516e4543089d0a6", size = 1842801, upload-time = "2026-06-07T21:06:16.228Z" },
{ url = "https://files.pythonhosted.org/packages/dc/d6/5a99b563690ea0cbed912ae94a2ce33993a5709a651a3a4fe761e7dd973a/aiohttp-3.14.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:797457503c2d426bee06eef808d07b31ede30b65e054444e7de64cad0061b7af", size = 1929992, upload-time = "2026-06-07T21:06:17.947Z" },
{ url = "https://files.pythonhosted.org/packages/76/7f/a987b14a3859094b3cea3f4825219c3e5536242564af6e3f9c2f6c994eb2/aiohttp-3.14.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b821a1f7dedf7e37450654e620038ac3b2e81e8fa6ea269337e97101978ec730", size = 1786989, upload-time = "2026-06-07T21:06:19.677Z" },
{ url = "https://files.pythonhosted.org/packages/f1/1a/420e5c85a3e73349372ed22ce0b6af86bfa6ce16a4b20a64a2e94608c781/aiohttp-3.14.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4cd96b5ba05d67ed0cf00b5b405c8cd99586d8e3481e8ee0a831057591af7621", size = 1640129, upload-time = "2026-06-07T21:06:22.558Z" },
{ url = "https://files.pythonhosted.org/packages/a7/80/18a592ed3be0a402cc03670bd72ee1f8563ddbe1d8d5542dbf868f274136/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d459b98a932296c6f0e94f87511a0b1b90a8a02c30a50e60a297619cd5a58ee", size = 1756576, upload-time = "2026-06-07T21:06:24.8Z" },
{ url = "https://files.pythonhosted.org/packages/ec/0b/8b3d5713373858ff71a617daf6e3b0e81ad63e79d09a3cf2f6b6b983939c/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:764457a7be60825fb770a644852ff717bcbb5042f189f2bd16df61a81b3f6573", size = 1754668, upload-time = "2026-06-07T21:06:26.528Z" },
{ url = "https://files.pythonhosted.org/packages/9f/49/fd564575cf225821d7ba5a117cb8bc27213d8a7e1811162afb43ae077039/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f7a16ef45b081454ef844502d87a848876c490c4cb5c650c230f6ec79ed2c1e7", size = 1817019, upload-time = "2026-06-07T21:06:28.297Z" },
{ url = "https://files.pythonhosted.org/packages/ed/1b/e850c9ae6fc91356552ae668bb6c51e93fa29c8aef13398a10b56678557f/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2fbc3ed048b3475b9f0cbcb9978e9d2d3511acd91ead203af26ed9f0056004cf", size = 1631638, upload-time = "2026-06-07T21:06:30.242Z" },
{ url = "https://files.pythonhosted.org/packages/eb/94/3c337ba72451a89806ace6f75bddc92bafc5b8d53d90115a512858024b63/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:bedb0cd073cc2dc035e30aeb99444389d3cd2113afe4ef9fcd23d439f5bade85", size = 1835660, upload-time = "2026-06-07T21:06:31.943Z" },
{ url = "https://files.pythonhosted.org/packages/2b/9c/9c18cf367a0498212d9ba7daf990b504a5e8ae064cda4b504e2647c89c03/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b6feea921016eb3d4e04d65fc4e9ca402d1a3801f562aef94989f54694917af3", size = 1775698, upload-time = "2026-06-07T21:06:33.72Z" },
{ url = "https://files.pythonhosted.org/packages/b5/63/a251a9d2a6cb45065b2ddc0bde2b3dd10108740a9a42f632c66405a761a2/aiohttp-3.14.1-cp311-cp311-win32.whl", hash = "sha256:313701e488100074ce99850404ee36e741abf6330179fec908a1944ecf570126", size = 458386, upload-time = "2026-06-07T21:06:35.279Z" },
{ url = "https://files.pythonhosted.org/packages/17/ca/69274c51dcd6e8947d77b2806cf47a4a15f2c846e2cbeb1882547d3da283/aiohttp-3.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:03ab4530fdcb3a543a122ba4b65ac9919da9fe9f78a03d328a6e38ff962f7aa5", size = 483406, upload-time = "2026-06-07T21:06:36.824Z" },
{ url = "https://files.pythonhosted.org/packages/2c/8a/c25904f77690c3688ec140f87591ef11a0cfe36bf3d5c0f1f38056fb62b3/aiohttp-3.14.1-cp311-cp311-win_arm64.whl", hash = "sha256:486f7d16ed54c39c2cbd7ca71fd8ba2b8bb7860df65bd7b6ed640bab96a38a8b", size = 452987, upload-time = "2026-06-07T21:06:38.371Z" },
{ url = "https://files.pythonhosted.org/packages/1d/21/151624b51cd92553d95424daf4bf19f19ce9be9002d19253e7e7ce67197b/aiohttp-3.14.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d35143e27778b4bb0fb189562d7f275bff79c62ab8e98459717c0ea617ff2480", size = 757402, upload-time = "2026-06-07T21:06:40.311Z" },
{ url = "https://files.pythonhosted.org/packages/c2/82/280619e0bd7bf2454987e19282616e84762255dd9c8468f62382e8c191f1/aiohttp-3.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bcfb80a2cc36fba2534e5e5b5264dc7ae6fcd9bf15256da3e53d2f499e6fa29d", size = 512310, upload-time = "2026-06-07T21:06:42.207Z" },
{ url = "https://files.pythonhosted.org/packages/55/b2/2aac325583aaa1353045f96dffa586d8a34e8322e14a7ba49cffeb103ab4/aiohttp-3.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27fd7c91e51729b4f7e1577865fa6d34c9adccbc39aabe9000285b48af9f0ec2", size = 512448, upload-time = "2026-06-07T21:06:43.813Z" },
{ url = "https://files.pythonhosted.org/packages/8a/72/a60607cb849faa8af8a356c9329ea2eb6f395d49e82cc82ccba1fd8deb8f/aiohttp-3.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:64c567bf9eaf664280116a8688f63016e6b32db2505908e2bdaca1b6438142f2", size = 1766854, upload-time = "2026-06-07T21:06:45.391Z" },
{ url = "https://files.pythonhosted.org/packages/b5/d3/d9fe1c9ec7557ab4d0d82bebaa728c6418f0b93295ec2f4ab015f7710cc7/aiohttp-3.14.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f5e6ff2bdbb8f4cd3fbe41f99e25bbcd58e3bf9f13d3dd31a11e7917251cc77a", size = 1740884, upload-time = "2026-06-07T21:06:47.413Z" },
{ url = "https://files.pythonhosted.org/packages/c1/dc/f2cecfaf9337ba3e63f181500814ff502aa3d00d9c7ec93a9d23d10a27b2/aiohttp-3.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2f73e01dc37122325caf079982621262f96d74823c179038a82fddfc50359264", size = 1810034, upload-time = "2026-06-07T21:06:50.165Z" },
{ url = "https://files.pythonhosted.org/packages/66/d7/2ff65c5e65c0d7476daf7e15c032e0805e36811185b9623e3238ad6c763e/aiohttp-3.14.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb2c0c80d431c0d03f2c7dbf125150fedd4f0de17366a7ca33f7ccb822391842", size = 1904054, upload-time = "2026-06-07T21:06:52.035Z" },
{ url = "https://files.pythonhosted.org/packages/20/9c/d445818389df371f56d141d881153ba23183c4735a03f7356ffb43f7757d/aiohttp-3.14.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e6fc1a85fa7194a1a7d19f44e8609180f4a8eb5fa4c7ed8b4355f080fad235c", size = 1790278, upload-time = "2026-06-07T21:06:54.049Z" },
{ url = "https://files.pythonhosted.org/packages/4d/aa/bf04cb4d865fc6101c2229a294ad744973b72e513fdc5a6b791e6983d72a/aiohttp-3.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:686b6c0d3911ec387b444ddf5dc62fb7f7c0a7d5186a7861626496a5ab4aff95", size = 1591795, upload-time = "2026-06-07T21:06:55.911Z" },
{ url = "https://files.pythonhosted.org/packages/dc/b4/4dac0038960427ba832f6609dfb4ea5437d7fd80c72001b9e48f834f428b/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c6fa4dc7ad6f8109c70bb1499e589f76b0b792baf39f9b017eb92c8a81d0a199", size = 1728397, upload-time = "2026-06-07T21:06:57.777Z" },
{ url = "https://files.pythonhosted.org/packages/2b/f9/7cd4e8ad7aa3b75f17d56bb5498dd604a93d4e6eece822ba0568c413fff0/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:87a5eea1b2a5e21e1ebdbb33ad4165359189327e63fc4e4894693e7f821ac817", size = 1766504, upload-time = "2026-06-07T21:07:00.009Z" },
{ url = "https://files.pythonhosted.org/packages/f9/df/fc01d9fcad0f73fed3f3d361f1f94f975947b50dff82919f6dc2bf4316cc/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c1421eb01d4fd608d88cc8290211d177a58532b55ad94076fb349c5bf467f0a", size = 1777806, upload-time = "2026-06-07T21:07:02.064Z" },
{ url = "https://files.pythonhosted.org/packages/41/09/47e2d090bddcc8fb4ccb4c314aadc32d7c5d9bb55f50f6ad1c92fc15d501/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:34b257ec41345c1e8f2df68fa908a7952f5de932723871eb633ecbbff396c9a4", size = 1580707, upload-time = "2026-06-07T21:07:03.942Z" },
{ url = "https://files.pythonhosted.org/packages/3d/36/f1a4ce904ae0b6930cfe9afc96d0896f7ec1a620c400405d63783bb95a9c/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:de538791a80e5d862addbc183f70f0158ac9b9bb872bb147f1fd2a683691e087", size = 1798121, upload-time = "2026-06-07T21:07:05.987Z" },
{ url = "https://files.pythonhosted.org/packages/70/0a/e0075ce9ca0279ee1d4f0c0b85f54fea02ebc83c3007651a72bece658fec/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f71173be42d3241d428f760122febb748de0623f44308a6f120d0dd9ec572e3", size = 1767580, upload-time = "2026-06-07T21:07:07.873Z" },
{ url = "https://files.pythonhosted.org/packages/3e/61/a0c0a8f327a9c52095cdd8e312391b00d3ed64ab6c72bb5c33d8ec251cf7/aiohttp-3.14.1-cp312-cp312-win32.whl", hash = "sha256:ec8dc383ee57ea3e883477dcca3f11b65d58199f1080acaf4cd6ad9a99698be4", size = 452771, upload-time = "2026-06-07T21:07:09.669Z" },
{ url = "https://files.pythonhosted.org/packages/df/d9/ea367c75f16ac9c6cdc8febb25e8318fa21a2b1bc8d6514d4b2d890bface/aiohttp-3.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:2aa92c87868cd13674989f9ee83e5f9f7ea4237589b728048e1f0c8f6caa3271", size = 479873, upload-time = "2026-06-07T21:07:11.538Z" },
{ url = "https://files.pythonhosted.org/packages/03/64/8d96784a7851156db8a4c6c3f6f91042fdf39fb15a4cc38c8b3c14833c45/aiohttp-3.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:2c840c90759922cb5e6dda94596e079a30fb5a5ba548e7e0dc00574703940847", size = 448073, upload-time = "2026-06-07T21:07:13.637Z" },
{ url = "https://files.pythonhosted.org/packages/bd/85/cebc47ee74d8b408749073a1a46c6fcba13d170dc8af7e61996c6c9394ac/aiohttp-3.13.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:02222e7e233295f40e011c1b00e3b0bd451f22cf853a0304c3595633ee47da4b", size = 750547, upload-time = "2026-03-31T21:56:30.024Z" },
{ url = "https://files.pythonhosted.org/packages/05/98/afd308e35b9d3d8c9ec54c0918f1d722c86dc17ddfec272fcdbcce5a3124/aiohttp-3.13.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bace460460ed20614fa6bc8cb09966c0b8517b8c58ad8046828c6078d25333b5", size = 503535, upload-time = "2026-03-31T21:56:31.935Z" },
{ url = "https://files.pythonhosted.org/packages/6f/4d/926c183e06b09d5270a309eb50fbde7b09782bfd305dec1e800f329834fb/aiohttp-3.13.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f546a4dc1e6a5edbb9fd1fd6ad18134550e096a5a43f4ad74acfbd834fc6670", size = 497830, upload-time = "2026-03-31T21:56:33.654Z" },
{ url = "https://files.pythonhosted.org/packages/e4/d6/f47d1c690f115a5c2a5e8938cce4a232a5be9aac5c5fb2647efcbbbda333/aiohttp-3.13.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c86969d012e51b8e415a8c6ce96f7857d6a87d6207303ab02d5d11ef0cad2274", size = 1682474, upload-time = "2026-03-31T21:56:35.513Z" },
{ url = "https://files.pythonhosted.org/packages/01/44/056fd37b1bb52eac760303e5196acc74d9d546631b035704ae5927f7b4ac/aiohttp-3.13.5-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b6f6cd1560c5fa427e3b6074bb24d2c64e225afbb7165008903bd42e4e33e28a", size = 1655259, upload-time = "2026-03-31T21:56:37.843Z" },
{ url = "https://files.pythonhosted.org/packages/91/9f/78eb1a20c1c28ae02f6a3c0f4d7b0dcc66abce5290cadd53d78ce3084175/aiohttp-3.13.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:636bc362f0c5bbc7372bc3ae49737f9e3030dbce469f0f422c8f38079780363d", size = 1736204, upload-time = "2026-03-31T21:56:39.822Z" },
{ url = "https://files.pythonhosted.org/packages/de/6c/d20d7de23f0b52b8c1d9e2033b2db1ac4dacbb470bb74c56de0f5f86bb4f/aiohttp-3.13.5-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6a7cbeb06d1070f1d14895eeeed4dac5913b22d7b456f2eb969f11f4b3993796", size = 1826198, upload-time = "2026-03-31T21:56:41.378Z" },
{ url = "https://files.pythonhosted.org/packages/2f/86/a6f3ff1fd795f49545a7c74b2c92f62729135d73e7e4055bf74da5a26c82/aiohttp-3.13.5-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bca9ef7517fd7874a1a08970ae88f497bf5c984610caa0bf40bd7e8450852b95", size = 1681329, upload-time = "2026-03-31T21:56:43.374Z" },
{ url = "https://files.pythonhosted.org/packages/fb/68/84cd3dab6b7b4f3e6fe9459a961acb142aaab846417f6e8905110d7027e5/aiohttp-3.13.5-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:019a67772e034a0e6b9b17c13d0a8fe56ad9fb150fc724b7f3ffd3724288d9e5", size = 1560023, upload-time = "2026-03-31T21:56:45.031Z" },
{ url = "https://files.pythonhosted.org/packages/41/2c/db61b64b0249e30f954a65ab4cb4970ced57544b1de2e3c98ee5dc24165f/aiohttp-3.13.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f34ecee82858e41dd217734f0c41a532bd066bcaab636ad830f03a30b2a96f2a", size = 1652372, upload-time = "2026-03-31T21:56:47.075Z" },
{ url = "https://files.pythonhosted.org/packages/25/6f/e96988a6c982d047810c772e28c43c64c300c943b0ed5c1c0c4ce1e1027c/aiohttp-3.13.5-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:4eac02d9af4813ee289cd63a361576da36dba57f5a1ab36377bc2600db0cbb73", size = 1662031, upload-time = "2026-03-31T21:56:48.835Z" },
{ url = "https://files.pythonhosted.org/packages/b7/26/a56feace81f3d347b4052403a9d03754a0ab23f7940780dada0849a38c92/aiohttp-3.13.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4beac52e9fe46d6abf98b0176a88154b742e878fdf209d2248e99fcdf73cd297", size = 1708118, upload-time = "2026-03-31T21:56:50.833Z" },
{ url = "https://files.pythonhosted.org/packages/78/6e/b6173a8ff03d01d5e1a694bc06764b5dad1df2d4ed8f0ceec12bb3277936/aiohttp-3.13.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:c180f480207a9b2475f2b8d8bd7204e47aec952d084b2a2be58a782ffcf96074", size = 1548667, upload-time = "2026-03-31T21:56:52.81Z" },
{ url = "https://files.pythonhosted.org/packages/16/13/13296ffe2c132d888b3fe2c195c8b9c0c24c89c3fa5cc2c44464dc23b22e/aiohttp-3.13.5-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2837fb92951564d6339cedae4a7231692aa9f73cbc4fb2e04263b96844e03b4e", size = 1724490, upload-time = "2026-03-31T21:56:54.541Z" },
{ url = "https://files.pythonhosted.org/packages/7a/b4/1f1c287f4a79782ef36e5a6e62954c85343bc30470d862d30bd5f26c9fa2/aiohttp-3.13.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d9010032a0b9710f58012a1e9c222528763d860ba2ee1422c03473eab47703e7", size = 1667109, upload-time = "2026-03-31T21:56:56.21Z" },
{ url = "https://files.pythonhosted.org/packages/ef/42/8461a2aaf60a8f4ea4549a4056be36b904b0eb03d97ca9a8a2604681a500/aiohttp-3.13.5-cp310-cp310-win32.whl", hash = "sha256:7c4b6668b2b2b9027f209ddf647f2a4407784b5d88b8be4efcc72036f365baf9", size = 439478, upload-time = "2026-03-31T21:56:58.292Z" },
{ url = "https://files.pythonhosted.org/packages/e5/71/06956304cb5ee439dfe8d86e1b2e70088bd88ed1ced1f42fb29e5d855f0e/aiohttp-3.13.5-cp310-cp310-win_amd64.whl", hash = "sha256:cd3db5927bf9167d5a6157ddb2f036f6b6b0ad001ac82355d43e97a4bde76d76", size = 462047, upload-time = "2026-03-31T21:57:00.257Z" },
{ url = "https://files.pythonhosted.org/packages/d6/f5/a20c4ac64aeaef1679e25c9983573618ff765d7aa829fa2b84ae7573169e/aiohttp-3.13.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ab7229b6f9b5c1ba4910d6c41a9eb11f543eadb3f384df1b4c293f4e73d44d6", size = 757513, upload-time = "2026-03-31T21:57:02.146Z" },
{ url = "https://files.pythonhosted.org/packages/75/0a/39fa6c6b179b53fcb3e4b3d2b6d6cad0180854eda17060c7218540102bef/aiohttp-3.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8f14c50708bb156b3a3ca7230b3d820199d56a48e3af76fa21c2d6087190fe3d", size = 506748, upload-time = "2026-03-31T21:57:04.275Z" },
{ url = "https://files.pythonhosted.org/packages/87/ec/e38ce072e724fd7add6243613f8d1810da084f54175353d25ccf9f9c7e5a/aiohttp-3.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e7d2f8616f0ff60bd332022279011776c3ac0faa0f1b463f7bb12326fbc97a1c", size = 501673, upload-time = "2026-03-31T21:57:06.208Z" },
{ url = "https://files.pythonhosted.org/packages/ba/ba/3bc7525d7e2beaa11b309a70d48b0d3cfc3c2089ec6a7d0820d59c657053/aiohttp-3.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2567b72e1ffc3ab25510db43f355b29eeada56c0a622e58dcdb19530eb0a3cb", size = 1763757, upload-time = "2026-03-31T21:57:07.882Z" },
{ url = "https://files.pythonhosted.org/packages/5e/ab/e87744cf18f1bd78263aba24924d4953b41086bd3a31d22452378e9028a0/aiohttp-3.13.5-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fb0540c854ac9c0c5ad495908fdfd3e332d553ec731698c0e29b1877ba0d2ec6", size = 1720152, upload-time = "2026-03-31T21:57:09.946Z" },
{ url = "https://files.pythonhosted.org/packages/6b/f3/ed17a6f2d742af17b50bae2d152315ed1b164b07a5fd5cc1754d99e4dfa5/aiohttp-3.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9883051c6972f58bfc4ebb2116345ee2aa151178e99c3f2b2bbe2af712abd13", size = 1818010, upload-time = "2026-03-31T21:57:12.157Z" },
{ url = "https://files.pythonhosted.org/packages/53/06/ecbc63dc937192e2a5cb46df4d3edb21deb8225535818802f210a6ea5816/aiohttp-3.13.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2294172ce08a82fb7c7273485895de1fa1186cc8294cfeb6aef4af42ad261174", size = 1907251, upload-time = "2026-03-31T21:57:14.023Z" },
{ url = "https://files.pythonhosted.org/packages/7e/a5/0521aa32c1ddf3aa1e71dcc466be0b7db2771907a13f18cddaa45967d97b/aiohttp-3.13.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3a807cabd5115fb55af198b98178997a5e0e57dead43eb74a93d9c07d6d4a7dc", size = 1759969, upload-time = "2026-03-31T21:57:16.146Z" },
{ url = "https://files.pythonhosted.org/packages/f6/78/a38f8c9105199dd3b9706745865a8a59d0041b6be0ca0cc4b2ccf1bab374/aiohttp-3.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa6d0d932e0f39c02b80744273cd5c388a2d9bc07760a03164f229c8e02662f6", size = 1616871, upload-time = "2026-03-31T21:57:17.856Z" },
{ url = "https://files.pythonhosted.org/packages/6f/41/27392a61ead8ab38072105c71aa44ff891e71653fe53d576a7067da2b4e8/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:60869c7ac4aaabe7110f26499f3e6e5696eae98144735b12a9c3d9eae2b51a49", size = 1739844, upload-time = "2026-03-31T21:57:19.679Z" },
{ url = "https://files.pythonhosted.org/packages/6e/55/5564e7ae26d94f3214250009a0b1c65a0c6af4bf88924ccb6fdab901de28/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:26d2f8546f1dfa75efa50c3488215a903c0168d253b75fba4210f57ab77a0fb8", size = 1731969, upload-time = "2026-03-31T21:57:22.006Z" },
{ url = "https://files.pythonhosted.org/packages/6d/c5/705a3929149865fc941bcbdd1047b238e4a72bcb215a9b16b9d7a2e8d992/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f1162a1492032c82f14271e831c8f4b49f2b6078f4f5fc74de2c912fa225d51d", size = 1795193, upload-time = "2026-03-31T21:57:24.256Z" },
{ url = "https://files.pythonhosted.org/packages/a6/19/edabed62f718d02cff7231ca0db4ef1c72504235bc467f7b67adb1679f48/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:8b14eb3262fad0dc2f89c1a43b13727e709504972186ff6a99a3ecaa77102b6c", size = 1606477, upload-time = "2026-03-31T21:57:26.364Z" },
{ url = "https://files.pythonhosted.org/packages/de/fc/76f80ef008675637d88d0b21584596dc27410a990b0918cb1e5776545b5b/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ca9ac61ac6db4eb6c2a0cd1d0f7e1357647b638ccc92f7e9d8d133e71ed3c6ac", size = 1813198, upload-time = "2026-03-31T21:57:28.316Z" },
{ url = "https://files.pythonhosted.org/packages/e5/67/5b3ac26b80adb20ea541c487f73730dc8fa107d632c998f25bbbab98fcda/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7996023b2ed59489ae4762256c8516df9820f751cf2c5da8ed2fb20ee50abab3", size = 1752321, upload-time = "2026-03-31T21:57:30.549Z" },
{ url = "https://files.pythonhosted.org/packages/88/06/e4a2e49255ea23fa4feeb5ab092d90240d927c15e47b5b5c48dff5a9ce29/aiohttp-3.13.5-cp311-cp311-win32.whl", hash = "sha256:77dfa48c9f8013271011e51c00f8ada19851f013cde2c48fca1ba5e0caf5bb06", size = 439069, upload-time = "2026-03-31T21:57:32.388Z" },
{ url = "https://files.pythonhosted.org/packages/c0/43/8c7163a596dab4f8be12c190cf467a1e07e4734cf90eebb39f7f5d53fc6a/aiohttp-3.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:d3a4834f221061624b8887090637db9ad4f61752001eae37d56c52fddade2dc8", size = 462859, upload-time = "2026-03-31T21:57:34.455Z" },
{ url = "https://files.pythonhosted.org/packages/be/6f/353954c29e7dcce7cf00280a02c75f30e133c00793c7a2ed3776d7b2f426/aiohttp-3.13.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:023ecba036ddd840b0b19bf195bfae970083fd7024ce1ac22e9bba90464620e9", size = 748876, upload-time = "2026-03-31T21:57:36.319Z" },
{ url = "https://files.pythonhosted.org/packages/f5/1b/428a7c64687b3b2e9cd293186695affc0e1e54a445d0361743b231f11066/aiohttp-3.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15c933ad7920b7d9a20de151efcd05a6e38302cbf0e10c9b2acb9a42210a2416", size = 499557, upload-time = "2026-03-31T21:57:38.236Z" },
{ url = "https://files.pythonhosted.org/packages/29/47/7be41556bfbb6917069d6a6634bb7dd5e163ba445b783a90d40f5ac7e3a7/aiohttp-3.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab2899f9fa2f9f741896ebb6fa07c4c883bfa5c7f2ddd8cf2aafa86fa981b2d2", size = 500258, upload-time = "2026-03-31T21:57:39.923Z" },
{ url = "https://files.pythonhosted.org/packages/67/84/c9ecc5828cb0b3695856c07c0a6817a99d51e2473400f705275a2b3d9239/aiohttp-3.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60eaa2d440cd4707696b52e40ed3e2b0f73f65be07fd0ef23b6b539c9c0b0b4", size = 1749199, upload-time = "2026-03-31T21:57:41.938Z" },
{ url = "https://files.pythonhosted.org/packages/f0/d3/3c6d610e66b495657622edb6ae7c7fd31b2e9086b4ec50b47897ad6042a9/aiohttp-3.13.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:55b3bdd3292283295774ab585160c4004f4f2f203946997f49aac032c84649e9", size = 1721013, upload-time = "2026-03-31T21:57:43.904Z" },
{ url = "https://files.pythonhosted.org/packages/49/a0/24409c12217456df0bae7babe3b014e460b0b38a8e60753d6cb339f6556d/aiohttp-3.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2b2355dc094e5f7d45a7bb262fe7207aa0460b37a0d87027dcf21b5d890e7d5", size = 1781501, upload-time = "2026-03-31T21:57:46.285Z" },
{ url = "https://files.pythonhosted.org/packages/98/9d/b65ec649adc5bccc008b0957a9a9c691070aeac4e41cea18559fef49958b/aiohttp-3.13.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b38765950832f7d728297689ad78f5f2cf79ff82487131c4d26fe6ceecdc5f8e", size = 1878981, upload-time = "2026-03-31T21:57:48.734Z" },
{ url = "https://files.pythonhosted.org/packages/57/d8/8d44036d7eb7b6a8ec4c5494ea0c8c8b94fbc0ed3991c1a7adf230df03bf/aiohttp-3.13.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b18f31b80d5a33661e08c89e202edabf1986e9b49c42b4504371daeaa11b47c1", size = 1767934, upload-time = "2026-03-31T21:57:51.171Z" },
{ url = "https://files.pythonhosted.org/packages/31/04/d3f8211f273356f158e3464e9e45484d3fb8c4ce5eb2f6fe9405c3273983/aiohttp-3.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:33add2463dde55c4f2d9635c6ab33ce154e5ecf322bd26d09af95c5f81cfa286", size = 1566671, upload-time = "2026-03-31T21:57:53.326Z" },
{ url = "https://files.pythonhosted.org/packages/41/db/073e4ebe00b78e2dfcacff734291651729a62953b48933d765dc513bf798/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:327cc432fdf1356fb4fbc6fe833ad4e9f6aacb71a8acaa5f1855e4b25910e4a9", size = 1705219, upload-time = "2026-03-31T21:57:55.385Z" },
{ url = "https://files.pythonhosted.org/packages/48/45/7dfba71a2f9fd97b15c95c06819de7eb38113d2cdb6319669195a7d64270/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7c35b0bf0b48a70b4cb4fc5d7bed9b932532728e124874355de1a0af8ec4bc88", size = 1743049, upload-time = "2026-03-31T21:57:57.341Z" },
{ url = "https://files.pythonhosted.org/packages/18/71/901db0061e0f717d226386a7f471bb59b19566f2cae5f0d93874b017271f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:df23d57718f24badef8656c49743e11a89fd6f5358fa8a7b96e728fda2abf7d3", size = 1749557, upload-time = "2026-03-31T21:57:59.626Z" },
{ url = "https://files.pythonhosted.org/packages/08/d5/41eebd16066e59cd43728fe74bce953d7402f2b4ddfdfef2c0e9f17ca274/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:02e048037a6501a5ec1f6fc9736135aec6eb8a004ce48838cb951c515f32c80b", size = 1558931, upload-time = "2026-03-31T21:58:01.972Z" },
{ url = "https://files.pythonhosted.org/packages/30/e6/4a799798bf05740e66c3a1161079bda7a3dd8e22ca392481d7a7f9af82a6/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31cebae8b26f8a615d2b546fee45d5ffb76852ae6450e2a03f42c9102260d6fe", size = 1774125, upload-time = "2026-03-31T21:58:04.007Z" },
{ url = "https://files.pythonhosted.org/packages/84/63/7749337c90f92bc2cb18f9560d67aa6258c7060d1397d21529b8004fcf6f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:888e78eb5ca55a615d285c3c09a7a91b42e9dd6fc699b166ebd5dee87c9ccf14", size = 1732427, upload-time = "2026-03-31T21:58:06.337Z" },
{ url = "https://files.pythonhosted.org/packages/98/de/cf2f44ff98d307e72fb97d5f5bbae3bfcb442f0ea9790c0bf5c5c2331404/aiohttp-3.13.5-cp312-cp312-win32.whl", hash = "sha256:8bd3ec6376e68a41f9f95f5ed170e2fcf22d4eb27a1f8cb361d0508f6e0557f3", size = 433534, upload-time = "2026-03-31T21:58:08.712Z" },
{ url = "https://files.pythonhosted.org/packages/aa/ca/eadf6f9c8fa5e31d40993e3db153fb5ed0b11008ad5d9de98a95045bed84/aiohttp-3.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:110e448e02c729bcebb18c60b9214a87ba33bac4a9fa5e9a5f139938b56c6cb1", size = 460446, upload-time = "2026-03-31T21:58:10.945Z" },
]
[[package]]
@@ -688,48 +684,48 @@ toml = [
[[package]]
name = "cryptography"
version = "48.0.1"
version = "48.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/12/45/870e7f4bef50e5f53b9f51d4428aee5290eedf58ba443f16b1ebb7ab8e66/cryptography-48.0.1.tar.gz", hash = "sha256:266f4ee051abb2f725b74ef8072b521ce1feacf685a3364fa6a6b45548db791a", size = 832989, upload-time = "2026-06-09T22:32:31.8Z" }
sdist = { url = "https://files.pythonhosted.org/packages/9f/a9/db8f313fdcd85d767d4973515e1db101f9c71f95fced83233de224673757/cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920", size = 832984, upload-time = "2026-05-04T22:59:38.133Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1b/bc/ee4137cbbe105652c0ee4252792b78fc8e7afa4b8e61d9d5dc05a7f45731/cryptography-48.0.1-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:3e4a1a3232eef2e6c732827d5722db29a0cc8b27af2a4d865b094cf954be9ca1", size = 8008324, upload-time = "2026-06-09T22:31:00.702Z" },
{ url = "https://files.pythonhosted.org/packages/d5/85/6379d42181bfc713094f081360fc5784d6c816b599d45e7f082502d173ce/cryptography-48.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:32143b24adb918f078134e1e230f1eb8cc04886b92c28b5f0041aaf3e5699225", size = 4696243, upload-time = "2026-06-09T22:32:33.446Z" },
{ url = "https://files.pythonhosted.org/packages/9c/87/c85d147b53323c7eb4d850920c8901377323c2a0ff8d79c262d4fee89aa2/cryptography-48.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0d27a5696721ef7a672b8c810f6aded391058e0b9486e63e6d93baf765da691", size = 4713235, upload-time = "2026-06-09T22:31:40.141Z" },
{ url = "https://files.pythonhosted.org/packages/79/58/67cbf8cf1ee7c54b439ca07bbecf8362c07afc11a3724fea70f745784add/cryptography-48.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:eb86ce1af36fe65041b6db9a8bb064ee621a7e5fded0f80d475ec243477cd242", size = 4702323, upload-time = "2026-06-09T22:31:42.191Z" },
{ url = "https://files.pythonhosted.org/packages/89/c6/24266ac10c47f6cd2a865f4446062b466da1d1f10b27189eac00e61bf0c9/cryptography-48.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b024e784ad6c077ee0147b35ea9cbfc1e34e1fd4c1dcca214c2794d73a12df08", size = 5300085, upload-time = "2026-06-09T22:31:58.703Z" },
{ url = "https://files.pythonhosted.org/packages/d2/bb/cc4b78784f97efc8c5874c2a9743708d172be6663024b34a0467885ae0c8/cryptography-48.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3752f2dbc8f07a30aad2932c986cea495b03bb554887828225da104f732852b6", size = 4746137, upload-time = "2026-06-09T22:31:31.01Z" },
{ url = "https://files.pythonhosted.org/packages/1f/52/0c44de3f5267f8fbe8e835138017522a333436166e406f0db9b9e6e3033f/cryptography-48.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:bd81490cd5801d755cf97bb68ac191f14b708470b1c7cf4580f669b9c9264cd8", size = 4333867, upload-time = "2026-06-09T22:32:28.096Z" },
{ url = "https://files.pythonhosted.org/packages/9a/2e/772d7adbfa931537bc401640b7cac9976bff689bda187833e5d63b428e49/cryptography-48.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:66fd0771e7b9c6dcd44cf1120690d2338d16d72795cf40cae2786a39eba65429", size = 4701805, upload-time = "2026-06-09T22:31:38.284Z" },
{ url = "https://files.pythonhosted.org/packages/f8/a3/b06844f303873493c963caf581c04df31c7035e0c1b0f02c4814d319ec80/cryptography-48.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:3fd2ca57062b241c856670b073487d2e86c4637937ca5601e48f97bf8e11fc8f", size = 5258461, upload-time = "2026-06-09T22:31:04.187Z" },
{ url = "https://files.pythonhosted.org/packages/9f/13/8b765e2e12b07c74941caadb9d1c8fdc006c4dfbf2b8f2d610519758954d/cryptography-48.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:0ee6ea481db1ab889cba043ec1eda17bb9c1ea79db6722f779c3667f9f70322f", size = 4745488, upload-time = "2026-06-09T22:32:30.07Z" },
{ url = "https://files.pythonhosted.org/packages/2e/aa/48972bce55049b32a94f4907eda4d75fa385aad8a39506cc2fc72196ecf0/cryptography-48.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f2ceef93cb096aa3c4cc4b5c94ca6131f9196d28c64d6111533402a9b2054d41", size = 4830256, upload-time = "2026-06-09T22:31:43.868Z" },
{ url = "https://files.pythonhosted.org/packages/47/a2/e5079a032fb85cf6005046ca92bbd78b0c82dad2b5751ab8c311659da06f/cryptography-48.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9bd3f92d76217892b15df84ca256c2c113d386fdda7a7d8691aeeced976507c6", size = 4979117, upload-time = "2026-06-09T22:31:05.845Z" },
{ url = "https://files.pythonhosted.org/packages/b7/a0/8f50cae9c74e718ed769d63ed5c74bd0ea830c9550a74629cebd1b9c7bc7/cryptography-48.0.1-cp311-abi3-win32.whl", hash = "sha256:b9a32b876490d66c8bcc9963ef220199569748434ab01a9d6aaeabf88e7f5158", size = 3304154, upload-time = "2026-06-09T22:32:16.845Z" },
{ url = "https://files.pythonhosted.org/packages/c5/69/0572c77dbace6fef72f33755bd52ea399c71367250d366237f8691826b9e/cryptography-48.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:39489bfca54c7a1f6b297efcd8bc608ab92d16c4ca631b0cad4da46724588b24", size = 3817138, upload-time = "2026-06-09T22:32:00.388Z" },
{ url = "https://files.pythonhosted.org/packages/ca/6c/00fa2a95997164c8b2072ce327c23d4ab20809ccc323ea5fab91e53a4bba/cryptography-48.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:4fdc69f8e4316bcf0c8c8ec1f26f285d12e8142d88d96c876a59a03be3f6ae67", size = 7987408, upload-time = "2026-06-09T22:32:20.777Z" },
{ url = "https://files.pythonhosted.org/packages/b0/d9/45f309a7e4e5f3f8f121d6d3be9e94024a7726ec598d6e08ae04edb2f04d/cryptography-48.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48fe40804d4caa2288f24e70ca8c64c42dd826da0ad7e4f1b41b2128d679e6c8", size = 4690196, upload-time = "2026-06-09T22:31:54.74Z" },
{ url = "https://files.pythonhosted.org/packages/5f/9f/a1bc8bcc798811b8527eb374bbccf30a3f3e806829d967118222bf1125eb/cryptography-48.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:86be3b1b0b6bf09482fb50a979c508d2950ed95f5621ec77f4e385962006b83a", size = 4696782, upload-time = "2026-06-09T22:31:45.615Z" },
{ url = "https://files.pythonhosted.org/packages/66/c2/81a4fb4e4373c500bb526bc337ac5719dd31dd15b970b84a238168c6aa08/cryptography-48.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4ab0a343c807bbcd90c971cd1ecf072937cd01847a9e002bef88fb47ac6be577", size = 4696618, upload-time = "2026-06-09T22:31:11.564Z" },
{ url = "https://files.pythonhosted.org/packages/e5/0b/aa68b221dde92d09cb29a024ede17550ee21e77a404e59fc093c82bb51e1/cryptography-48.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9621de99d2da096006b629979efd8ae7eb2d8b822488d0c89ee4000c306c59b1", size = 5289970, upload-time = "2026-06-09T22:31:20.368Z" },
{ url = "https://files.pythonhosted.org/packages/78/13/fba657f958d2af66ea959a4ba01212632089249d34af1ae48054136344d7/cryptography-48.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:88c852a0ae366e262e5a1744b685e6a433dc8788dd2a277e418bf4904203609d", size = 4731873, upload-time = "2026-06-09T22:31:22.253Z" },
{ url = "https://files.pythonhosted.org/packages/4c/4c/9a964756d24a26b3e34dfcb16f961b89838786e6700b635b0d1e3adff4b6/cryptography-48.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:43c5835e2cb98c8733d86f57d6fc879b613f5c3478607281c3e36daffc6dd8a6", size = 4330804, upload-time = "2026-06-09T22:31:36.56Z" },
{ url = "https://files.pythonhosted.org/packages/4b/0f/a10f3a6eb12950a10e3a874070283aa2dd5875b2bfd15fad8a3e17b3f13e/cryptography-48.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:fe0180af5bf9236518a087e35bf2d9a347d5f5f51e63c579d683ddff424e3d46", size = 4696217, upload-time = "2026-06-09T22:31:13.351Z" },
{ url = "https://files.pythonhosted.org/packages/f3/6f/5cd12f951165ea73ef85266775d97e4c763b2474ccfd816dd69d3a18d6f8/cryptography-48.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:b7a2d1a937a738a881737cec135a38bb61470589b17515b9f73f571d0ae10401", size = 5245252, upload-time = "2026-06-09T22:32:02.193Z" },
{ url = "https://files.pythonhosted.org/packages/68/ab/8aaa12e4516ec4464033ab79b6f3b592bd5a92102467c4ace8a0d970203f/cryptography-48.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b74ca3b8e5ecdd833bf6a002ca41b4793bb27fb8f1c06ffaf2643c9e9140e31b", size = 4731388, upload-time = "2026-06-09T22:32:04.019Z" },
{ url = "https://files.pythonhosted.org/packages/1b/24/50027ea4dca85ec1f40688f3c24fb32ccacd520583c9592c3cc95628e6fb/cryptography-48.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2c37f2461406063b417837f5f3daab668652acd82423efcd7f0a9f04be972de1", size = 4824186, upload-time = "2026-06-09T22:32:18.707Z" },
{ url = "https://files.pythonhosted.org/packages/52/41/04cb5eb17085ade6f50cc611fb657df6a0f5885350de8764ece89c050197/cryptography-48.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:86fe77abb1bd87afb251d4d02ada7ecf53a32cee9b67d976abb2e45a13297475", size = 4964539, upload-time = "2026-06-09T22:31:18.793Z" },
{ url = "https://files.pythonhosted.org/packages/36/bf/ed70785c496e89d7e73b7cda2d21f2447fd6d4e821714b8d04ff217fed92/cryptography-48.0.1-cp39-abi3-win32.whl", hash = "sha256:6b2c0c3e6ccf3ade7750f836ef3ee36eea250cc467d45c256895573ac08cc6f1", size = 3282307, upload-time = "2026-06-09T22:30:53.162Z" },
{ url = "https://files.pythonhosted.org/packages/b3/ff/371ea7d252656ee1eb6d83eeeef3d1d0c6baf1d6497687d081ea03814670/cryptography-48.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:9a49ca6c81417f6a5edb50375a60cccdd70fa0a91a5211829dbea74eba94d2ac", size = 3793408, upload-time = "2026-06-09T22:32:15.191Z" },
{ url = "https://files.pythonhosted.org/packages/a9/d3/eb4e394e587341fdad09a09101fa76478ead3a78b0ad63e55c22f0d75c02/cryptography-48.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:08a597acce1ff37f347400087776599e2348a3a8bc53b44120e463cd274efe4a", size = 3951747, upload-time = "2026-06-09T22:31:23.871Z" },
{ url = "https://files.pythonhosted.org/packages/e0/4a/3f43451b4f858bfceaaaffc649e6e787e8d4fb332a1d443af39ab02cc8f1/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:735824ec41b7f74a7c45fb1591349333e4c696cb6c044e5f46356e560143e4cd", size = 4641226, upload-time = "2026-06-09T22:31:02.532Z" },
{ url = "https://files.pythonhosted.org/packages/73/4e/855584c2c23b09e4ce2d3b9c30e983e679cd60b068c513c6bbdb91e11782/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:92a46e1d638daa264ba2971c0b0489c9409787943efae4d60ffda3d091ef832c", size = 4668958, upload-time = "2026-06-09T22:32:06.213Z" },
{ url = "https://files.pythonhosted.org/packages/42/3b/d35750e41d803d1e516fd6d6011f065424924da7af1748cef4cc9cb3ede1/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:7e234ac052af99f2700826a5c29ea99d9c1b1f80341cde62d11c8154dc8e0bd9", size = 4640793, upload-time = "2026-06-09T22:32:26.331Z" },
{ url = "https://files.pythonhosted.org/packages/ca/aa/cdb7181fe865285e87e96825aaab239400f1de0c3bfba9bd9769b79f1a92/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:33842cf0888951cef5bc7ac724ab844a42044c1727b967b7f8997289a0464f92", size = 4668505, upload-time = "2026-06-09T22:31:27.534Z" },
{ url = "https://files.pythonhosted.org/packages/5d/8c/ce3823c06c2804f194f9e64f0d67fa3f4094a39f2bb1a990cd03603af8fc/cryptography-48.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6184ca7b174f28d7c703f1290d4b297217c45355f77a98f67e9b7f14549ac54a", size = 3742204, upload-time = "2026-06-09T22:31:34.773Z" },
{ url = "https://files.pythonhosted.org/packages/df/3d/01f6dd9190170a5a241e0e98c2d04be3664a9e6f5b9b872cde63aff1c3dd/cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6", size = 8001587, upload-time = "2026-05-04T22:57:36.803Z" },
{ url = "https://files.pythonhosted.org/packages/b2/6e/e90527eef33f309beb811cf7c982c3aeffcce8e3edb178baa4ca3ae4a6fa/cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c", size = 4690433, upload-time = "2026-05-04T22:57:40.373Z" },
{ url = "https://files.pythonhosted.org/packages/90/04/673510ed51ddff56575f306cf1617d80411ee76831ccd3097599140efdfe/cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3", size = 4710620, upload-time = "2026-05-04T22:57:42.935Z" },
{ url = "https://files.pythonhosted.org/packages/14/d5/e9c4ef932c8d800490c34d8bd589d64a31d5890e27ec9e9ad532be893294/cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5", size = 4696283, upload-time = "2026-05-04T22:57:45.294Z" },
{ url = "https://files.pythonhosted.org/packages/0c/29/174b9dfb60b12d59ecfc6cfa04bc88c21b42a54f01b8aae09bb6e51e4c7f/cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c", size = 5296573, upload-time = "2026-05-04T22:57:47.933Z" },
{ url = "https://files.pythonhosted.org/packages/95/38/0d29a6fd7d0d1373f0c0c88a04ba20e359b257753ac497564cd660fc1d55/cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f", size = 4743677, upload-time = "2026-05-04T22:57:50.067Z" },
{ url = "https://files.pythonhosted.org/packages/30/be/eef653013d5c63b6a490529e0316f9ac14a37602965d4903efed1399f32b/cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25", size = 4330808, upload-time = "2026-05-04T22:57:52.301Z" },
{ url = "https://files.pythonhosted.org/packages/84/9e/500463e87abb7a0a0f9f256ec21123ecde0a7b5541a15e840ea54551fd81/cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602", size = 4695941, upload-time = "2026-05-04T22:57:54.603Z" },
{ url = "https://files.pythonhosted.org/packages/e3/dc/7303087450c2ec9e7fbb750e17c2abfbc658f23cbd0e54009509b7cc4091/cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c", size = 5252579, upload-time = "2026-05-04T22:57:57.207Z" },
{ url = "https://files.pythonhosted.org/packages/d0/c0/7101d3b7215edcdc90c45da544961fd8ed2d6448f77577460fa75a8443f7/cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5", size = 4743326, upload-time = "2026-05-04T22:57:59.535Z" },
{ url = "https://files.pythonhosted.org/packages/ac/d8/5b833bad13016f562ab9d063d68199a4bd121d18458e439515601d3357ec/cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321", size = 4826672, upload-time = "2026-05-04T22:58:01.996Z" },
{ url = "https://files.pythonhosted.org/packages/98/e1/7074eb8bf3c135558c73fc2bcf0f5633f912e6fb87e868a55c454080ef09/cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74", size = 4972574, upload-time = "2026-05-04T22:58:03.968Z" },
{ url = "https://files.pythonhosted.org/packages/04/70/e5a1b41d325f797f39427aa44ef8baf0be500065ab6d8e10369d850d4a4f/cryptography-48.0.0-cp311-abi3-win32.whl", hash = "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4", size = 3294868, upload-time = "2026-05-04T22:58:06.467Z" },
{ url = "https://files.pythonhosted.org/packages/f4/ac/8ac51b4a5fc5932eb7ee5c517ba7dc8cd834f0048962b6b352f00f41ebf9/cryptography-48.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7", size = 3817107, upload-time = "2026-05-04T22:58:08.845Z" },
{ url = "https://files.pythonhosted.org/packages/f2/63/61d4a4e1c6b6bab6ce1e213cd36a24c415d90e76d78c5eb8577c5541d2e8/cryptography-48.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86", size = 7983482, upload-time = "2026-05-04T22:58:43.769Z" },
{ url = "https://files.pythonhosted.org/packages/d5/ac/f5b5995b87770c693e2596559ffafe195b4033a57f14a82268a2842953f3/cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e", size = 4683266, upload-time = "2026-05-04T22:58:46.064Z" },
{ url = "https://files.pythonhosted.org/packages/ec/c6/8b14f67e18338fbc4adb76f66c001f5c3610b3e2d1837f268f47a347dbbb/cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f", size = 4696228, upload-time = "2026-05-04T22:58:48.22Z" },
{ url = "https://files.pythonhosted.org/packages/ea/73/f808fbae9514bd91b47875b003f13e284c8c6bdfd904b7944e803937eec1/cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7", size = 4689097, upload-time = "2026-05-04T22:58:50.9Z" },
{ url = "https://files.pythonhosted.org/packages/93/01/d86632d7d28db8ae83221995752eeb6639ffb374c2d22955648cf8d52797/cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832", size = 5283582, upload-time = "2026-05-04T22:58:53.017Z" },
{ url = "https://files.pythonhosted.org/packages/02/e1/50edc7a50334807cc4791fc4a0ce7468b4a1416d9138eab358bfc9a3d70b/cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c", size = 4730479, upload-time = "2026-05-04T22:58:55.611Z" },
{ url = "https://files.pythonhosted.org/packages/6f/af/99a582b1b1641ff5911ac559beb45097cf79efd4ead4657f578ef1af2d47/cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a", size = 4326481, upload-time = "2026-05-04T22:58:57.607Z" },
{ url = "https://files.pythonhosted.org/packages/90/ee/89aa26a06ef0a7d7611788ffd571a7c50e368cc6a4d5eef8b4884e866edb/cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a", size = 4688713, upload-time = "2026-05-04T22:59:00.077Z" },
{ url = "https://files.pythonhosted.org/packages/70/ba/bcb1b0bb7a33d4c7c0c4d4c7874b4a62ae4f56113a5f4baefa362dfb1f0f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a", size = 5238165, upload-time = "2026-05-04T22:59:02.317Z" },
{ url = "https://files.pythonhosted.org/packages/c9/70/ca4003b1ce5ca3dc3186ada51908c8a9b9ff7d5cab83cc0d43ee14ec144f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239", size = 4729947, upload-time = "2026-05-04T22:59:05.255Z" },
{ url = "https://files.pythonhosted.org/packages/44/a0/4ec7cf774207905aef1a8d11c3750d5a1db805eb380ee4e16df317870128/cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c", size = 4822059, upload-time = "2026-05-04T22:59:07.802Z" },
{ url = "https://files.pythonhosted.org/packages/1e/75/a2e55f99c16fcac7b5d6c1eb19ad8e00799854d6be5ca845f9259eae1681/cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4", size = 4960575, upload-time = "2026-05-04T22:59:09.851Z" },
{ url = "https://files.pythonhosted.org/packages/b8/23/6e6f32143ab5d8b36ca848a502c4bcd477ae75b9e1677e3530d669062578/cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd", size = 3279117, upload-time = "2026-05-04T22:59:12.019Z" },
{ url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" },
{ url = "https://files.pythonhosted.org/packages/be/d2/024b5e06be9d44cb021fb0e1a03d34d63989cf56a0fe62f3dfbab695b9b4/cryptography-48.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:84cf79f0dc8b36ac5da873481716e87aef31fcfa0444f9e1d8b4b2cece142855", size = 3950391, upload-time = "2026-05-04T22:59:17.415Z" },
{ url = "https://files.pythonhosted.org/packages/bc/17/3861e17c56fa0fd37491a14a8673fdb77c57fc5693cafe745ea8b06dba75/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:fdfef35d751d510fcef5252703621574364fec16418c4a1e5e1055248401054b", size = 4637126, upload-time = "2026-05-04T22:59:20.197Z" },
{ url = "https://files.pythonhosted.org/packages/f0/0a/7e226dbff530f21480727eb764973a7bff2b912f8e15cd4f129e71b56d1d/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:0890f502ddf7d9c6426129c3f49f5c0a39278ed7cd6322c8755ffca6ee675a13", size = 4667270, upload-time = "2026-05-04T22:59:22.647Z" },
{ url = "https://files.pythonhosted.org/packages/3b/f2/5a72274ca9f1b2a8b44a662ee0bf1b435909deb473d6f97bcd035bcdbc71/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:ecde28a596bead48b0cfd2a1b4416c3d43074c2d785e3a398d7ec1fc4d0f7fbb", size = 4636797, upload-time = "2026-05-04T22:59:24.912Z" },
{ url = "https://files.pythonhosted.org/packages/b4/e1/48cedb2fe63626e91ded1edad159e2a4fb8b6906c4425eb7749673077ce7/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:4defde8685ae324a9eb9d818717e93b4638ef67070ac9bc15b8ca85f63048355", size = 4666800, upload-time = "2026-05-04T22:59:27.474Z" },
{ url = "https://files.pythonhosted.org/packages/a2/ca/7e8365deec19afb2b2c7be7c1c0aa8f99633b54e90c570999acda93260fc/cryptography-48.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:db63bf618e5dea46c07de12e900fe1cdd2541e6dc9dbae772a70b7d4d4765f6a", size = 3739536, upload-time = "2026-05-04T22:59:29.61Z" },
]
[[package]]
@@ -1288,7 +1284,7 @@ wheels = [
[[package]]
name = "lnbits"
version = "1.5.5rc3"
version = "1.5.5rc2"
source = { editable = "." }
dependencies = [
{ name = "aiosqlite" },
@@ -2282,11 +2278,11 @@ wheels = [
[[package]]
name = "python-multipart"
version = "0.0.31"
version = "0.0.29"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/64/7e/9b35ad8f3d9ca680f7c87a88f19612fdd8da9796c4d3b46e560ac79dcc4a/python_multipart-0.0.31.tar.gz", hash = "sha256:fc631183bb13e56db3158a4909908dfb2e23565286744e798241e63750e5d680", size = 46689, upload-time = "2026-06-04T08:27:49.014Z" }
sdist = { url = "https://files.pythonhosted.org/packages/4e/fe/70bd71a6738b09a0bdf6480ca6436b167469ca4578b2a0efbe390b4b0e70/python_multipart-0.0.29.tar.gz", hash = "sha256:643e93849196645e2dbdd81a0f8829a23123ad7f797a84a364c6fb3563f18904", size = 45678, upload-time = "2026-05-17T17:29:47.654Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5e/1e/7f7f299527a5a8ad90acd5f2f78dfa6c8495c6301a3205106ea68a84de96/python_multipart-0.0.31-py3-none-any.whl", hash = "sha256:8408153d68a9773291fc1da39a8b85a50044bddbabd2dd72e9229776b7b15e28", size = 29996, upload-time = "2026-06-04T08:27:47.804Z" },
{ url = "https://files.pythonhosted.org/packages/8f/cb/769cfc37177252872a45a71f3fbdde9d51b471a3f3c14bfe95dde3407386/python_multipart-0.0.29-py3-none-any.whl", hash = "sha256:2ddcc971cef266225f54f552d8fa10bcfbb1f14446caec199060daac59ff2d69", size = 29640, upload-time = "2026-05-17T17:29:45.69Z" },
]
[[package]]
@@ -2686,19 +2682,19 @@ wheels = [
[[package]]
name = "tornado"
version = "6.5.7"
version = "6.5.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/64/24/95ec527ad67b76d59299e5465b3935d05e4294b7e0290a3924b7487df30b/tornado-6.5.7.tar.gz", hash = "sha256:66c513a76cda70d53907bc27cf1447557699c2e95aa48ba27a442ff61c3ddfc2", size = 519252, upload-time = "2026-06-08T17:34:51.232Z" }
sdist = { url = "https://files.pythonhosted.org/packages/50/57/6d7303a77ae439d9189108f76c0c4fd89ee5e2cc8387bffb55232565c4ed/tornado-6.5.6.tar.gz", hash = "sha256:9a365179fe8ff6b8766f602c0f67c185d778193e9bdd828b19f0b6ed7764177d", size = 518139, upload-time = "2026-05-27T15:35:54.646Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/02/dc/c7043cab6fed8ae159fc1923ce829ada35c4dbd797d408a43858ffaf9639/tornado-6.5.7-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:148b2eb15c2c765a50796172c1e499649b35f30d2e3c3d3e15913cfa56bfb163", size = 448543, upload-time = "2026-06-08T17:34:38.052Z" },
{ url = "https://files.pythonhosted.org/packages/92/4f/090b1431e5a43df696feceffc268c5383cc079ecb5f08ce58f917109aafe/tornado-6.5.7-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:9da38de27f1da3b78a966f0dae12b5a1ea9afe72ca805d84ff06508272ddf100", size = 446707, upload-time = "2026-06-08T17:34:39.594Z" },
{ url = "https://files.pythonhosted.org/packages/37/d8/ef374952fd5da67d4463122c2b8e5a96536ec10b4b339254c6dcde81d01c/tornado-6.5.7-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8d759e71906ee783f8867b93bf26a265743da4c1e2f4a018464c1ba019862972", size = 449774, upload-time = "2026-06-08T17:34:41.204Z" },
{ url = "https://files.pythonhosted.org/packages/35/37/d434c73f4c6e014b745b9b37085f34f40c022f007efff3d7fe65991899f3/tornado-6.5.7-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a46347a18f23fb92b396beebe0fb78f61dda0cc302445202c16203d8a18848b", size = 450745, upload-time = "2026-06-08T17:34:42.531Z" },
{ url = "https://files.pythonhosted.org/packages/b6/2b/56b9aff361d7f1ab728a805ec7d7ea835f8807afa9f5cc690ea0e630efb9/tornado-6.5.7-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7778b30bef919231265e91c69963ce0f49a1e9c07ac900bbe75b19ce2575ba92", size = 450578, upload-time = "2026-06-08T17:34:43.787Z" },
{ url = "https://files.pythonhosted.org/packages/02/30/a7444fb23aa76860a14198fab96ac79f1866b0a6e19e26c4381b0938e50f/tornado-6.5.7-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e726f0c75da7726eec023aa62751ff8878bd2737e34fbdd33b1ae5897d2200f5", size = 449985, upload-time = "2026-06-08T17:34:45.326Z" },
{ url = "https://files.pythonhosted.org/packages/5c/42/5f0e56c01e8d9d36f4e23f367b85ae6cae0c1ecddd5e6977d8388ad27488/tornado-6.5.7-cp39-abi3-win32.whl", hash = "sha256:f8de3bf12d3efdd0cbe7c8887868198f8a91415e3f29fcf258d9b8eb7b1d9ae4", size = 451047, upload-time = "2026-06-08T17:34:46.784Z" },
{ url = "https://files.pythonhosted.org/packages/c9/a4/b393076ffb21b469eec5b328a0534cf03a3b90bfc6b1f09507cdd075d938/tornado-6.5.7-cp39-abi3-win_amd64.whl", hash = "sha256:de942f843533a039ef9fa3d9c88c7cd8a7c94553fb5ad0154270989b3d99a2c4", size = 451485, upload-time = "2026-06-08T17:34:48.248Z" },
{ url = "https://files.pythonhosted.org/packages/71/2e/7b1c769803121b809112cf9a00681c472eae1d80e32d7ec0e0bd61d0d0e1/tornado-6.5.7-cp39-abi3-win_arm64.whl", hash = "sha256:ff934fce95643af5f11efdae618eaa73d469dc588641e5c8d19295a0c65c4796", size = 450506, upload-time = "2026-06-08T17:34:49.702Z" },
{ url = "https://files.pythonhosted.org/packages/1b/0d/b4f481e18c5a51864e6d12b9a05ecf72919696680b747c958c3fc1f4fbae/tornado-6.5.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:65fcfaafb079435c2c19dc9e07c0f1cf0fa9051759ed0a7d0a3ba7ea7f64919c", size = 447737, upload-time = "2026-05-27T15:35:38.122Z" },
{ url = "https://files.pythonhosted.org/packages/9e/9c/5430c39fcab1144d35860f457b15e9c08b4bc7ac86764354204e983d6183/tornado-6.5.6-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:38bc01b4acacded2de63ae78023548e41ebe6fbed3ec05a796d7ae3ad893887e", size = 445899, upload-time = "2026-05-27T15:35:40.519Z" },
{ url = "https://files.pythonhosted.org/packages/8b/79/fa7e14a2f939c807a8d30619b4eb604eab219601b78792516ebe22d40cf9/tornado-6.5.6-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b942e6a137fda31ff54bf8e6e2c8d1c37f1f50583f3ed53fb840b53b9601d104", size = 448964, upload-time = "2026-05-27T15:35:42.106Z" },
{ url = "https://files.pythonhosted.org/packages/a7/71/bd67d5f5199f937dafe03a49a37989f60f600ff6fef34c79412a829d97bd/tornado-6.5.6-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8666946e70171b8c3f1fc9b7876fac492e84822c4c7f3746f4e8f8bc9ac92a79", size = 449935, upload-time = "2026-05-27T15:35:43.906Z" },
{ url = "https://files.pythonhosted.org/packages/cc/a4/c24388c9cf5b3c3a513b56a158af9f23092c9a2810d789e294310797df21/tornado-6.5.6-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1c34cfab7ad6d104f052f55de06d39bbafc5885cfeb4da688803308dbcfa90b7", size = 449767, upload-time = "2026-05-27T15:35:45.793Z" },
{ url = "https://files.pythonhosted.org/packages/a5/eb/6a07ad550c3f7b37244bd0becdf293ec3d3e961783d8b720a97df50de1b2/tornado-6.5.6-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:385f35e4e22fb52551dfcda4cdc8c30c61c2c001aef5ddad99cdfe116952efd3", size = 449174, upload-time = "2026-05-27T15:35:47.485Z" },
{ url = "https://files.pythonhosted.org/packages/bb/84/3469e098dccdb6763130e06aacd786bb4363fca7b590a55c101ddf34ed30/tornado-6.5.6-cp39-abi3-win32.whl", hash = "sha256:db475f1b67b2809b10bb16264829087724ca8d24fe4ed47f7b8675cae453ef86", size = 450230, upload-time = "2026-05-27T15:35:49.322Z" },
{ url = "https://files.pythonhosted.org/packages/d2/3c/273a04e0b9dd9016f1685cca0c1c8795a71ac88a34a8c889a0b443483226/tornado-6.5.6-cp39-abi3-win_amd64.whl", hash = "sha256:6739bf1e8eb09230f1280ddbd3236f0309db70f2c551a8dbc40f62babdf82f79", size = 450667, upload-time = "2026-05-27T15:35:51.194Z" },
{ url = "https://files.pythonhosted.org/packages/02/98/0cffe22a224f60c5fb1e3aa0b76f9da2e1ca78b0e9545e3d077c68ce60a7/tornado-6.5.6-cp39-abi3-win_arm64.whl", hash = "sha256:2543597b24a695d72338a9a77818362d72387c03ae173f1f169eadc5c91466ac", size = 449690, upload-time = "2026-05-27T15:35:52.902Z" },
]
[[package]]