Add formatting and type checks

This commit is contained in:
StasonJatham committed 2026-05-22 17:05:06 +02:00
1 parent c0d82eaf26
commit 24747866bc
38 files changed
+800 -265

No files matched your search

+2
View File
@@ -427,7 +427,9 @@ Backend layout:
| `karldns/dns/` | Cloudflare and RFC 2136 DNS publishing. | | `karldns/dns/` | Cloudflare and RFC 2136 DNS publishing. |
```bash ```bash
black --check karldns tests
ruff check karldns tests ruff check karldns tests
mypy karldns
pytest pytest
python -m compileall karldns python -m compileall karldns
``` ```
+6 -3
View File
@@ -4,6 +4,7 @@ import json
import urllib.error import urllib.error
import urllib.parse import urllib.parse
import urllib.request import urllib.request
from typing import Any, cast
from ..models import UpdateResult from ..models import UpdateResult
from ..settings import DdnsSettings from ..settings import DdnsSettings
@@ -49,11 +50,13 @@ def _cloudflare_upsert_record(settings: DdnsSettings, hostname: str, record_type
_cloudflare_reconcile_record(settings, hostname, record_type, value) _cloudflare_reconcile_record(settings, hostname, record_type, value)
def _cloudflare_find_record(settings: DdnsSettings, hostname: str, record_type: str) -> dict[str, str] | None: def _cloudflare_find_record(settings: DdnsSettings, hostname: str, record_type: str) -> dict[str, Any] | None:
query = urllib.parse.urlencode({"type": record_type, "name": hostname}) query = urllib.parse.urlencode({"type": record_type, "name": hostname})
response = _cloudflare_request(settings, f"/dns_records?{query}") response = _cloudflare_request(settings, f"/dns_records?{query}")
records = response.get("result", []) records = response.get("result", [])
return records[0] if records else None if not isinstance(records, list) or not records:
return None
return cast(dict[str, Any], records[0])
def _cloudflare_reconcile_record(settings: DdnsSettings, hostname: str, record_type: str, value: str) -> None: def _cloudflare_reconcile_record(settings: DdnsSettings, hostname: str, record_type: str, value: str) -> None:
@@ -69,7 +72,7 @@ def _cloudflare_request(
path: str, path: str,
method: str = "GET", method: str = "GET",
payload: dict[str, object] | None = None, payload: dict[str, object] | None = None,
) -> dict[str, object]: ) -> dict[str, Any]:
url = f"https://api.cloudflare.com/client/v4/zones/{settings.cloudflare_zone_id}{path}" url = f"https://api.cloudflare.com/client/v4/zones/{settings.cloudflare_zone_id}{path}"
body = json.dumps(payload).encode("utf-8") if payload is not None else None body = json.dumps(payload).encode("utf-8") if payload is not None else None
request = urllib.request.Request(url, data=body, method=method) request = urllib.request.Request(url, data=body, method=method)
+18 -3
View File
@@ -67,7 +67,9 @@ def make_router(admin_path: str = "/admin") -> APIRouter:
cleanup_events: int = Query(0, ge=0), cleanup_events: int = Query(0, ge=0),
) -> str: ) -> str:
notice = ( notice = (
_admin_notice("success", f"Cleanup finished. Removed {cleanup_unused} hostnames and {cleanup_events} old events.") _admin_notice(
"success", f"Cleanup finished. Removed {cleanup_unused} hostnames and {cleanup_events} old events."
)
if cleanup_done if cleanup_done
else None else None
) )
@@ -138,7 +140,13 @@ def make_router(admin_path: str = "/admin") -> APIRouter:
"router update URL rotated by admin", "router update URL rotated by admin",
client_ip(request, settings), client_ip(request, settings),
) )
notice = _admin_secret_panel("Update URL changed", [_copy_row("Update-URL:", service.fritz_update_url(rotated), "Secret")]) if rotated else None notice = (
_admin_secret_panel(
"Update URL changed", [_copy_row("Update-URL:", service.fritz_update_url(rotated), "Secret")]
)
if rotated
else None
)
elif action == "management": elif action == "management":
rotated = await run_in_threadpool(store.rotate_management_slug, hostname) rotated = await run_in_threadpool(store.rotate_management_slug, hostname)
await run_in_threadpool( await run_in_threadpool(
@@ -150,7 +158,14 @@ def make_router(admin_path: str = "/admin") -> APIRouter:
"dashboard link rotated by admin", "dashboard link rotated by admin",
client_ip(request, settings), client_ip(request, settings),
) )
notice = _admin_secret_panel("Dashboard link changed", [_copy_row("Private dashboard:", service.dashboard_url(rotated), "Secret")]) if rotated else None notice = (
_admin_secret_panel(
"Dashboard link changed",
[_copy_row("Private dashboard:", service.dashboard_url(rotated), "Secret")],
)
if rotated
else None
)
else: else:
raise HTTPException(status_code=400, detail="invalid rotation action") raise HTTPException(status_code=400, detail="invalid rotation action")
page = await run_in_threadpool(render_admin, service, settings, store, None, notice) page = await run_in_threadpool(render_admin, service, settings, store, None, notice)
+25 -6
View File
@@ -16,8 +16,12 @@ from ..schemas import (
HostnameCreateResponse, HostnameCreateResponse,
HostnameRotateUpdateResponse, HostnameRotateUpdateResponse,
HostnameStatusResponse, HostnameStatusResponse,
_admin_cleanup_run,
_admin_hostname, _admin_hostname,
_admin_metrics, _admin_metrics,
_admin_record,
_admin_scheduled_task,
_admin_update_event,
_create_response, _create_response,
_hostname_rotate_update_response, _hostname_rotate_update_response,
_hostname_status, _hostname_status,
@@ -62,7 +66,11 @@ async def read_hostname(
if not account or account["disabled"]: if not account or account["disabled"]:
raise HTTPException(status_code=404, detail="not found") raise HTTPException(status_code=404, detail="not found")
account["management_slug"] = dashboard_secret account["management_slug"] = dashboard_secret
history = await run_in_threadpool(store.list_update_events_for_hostname, str(account["hostname"]), history_limit) if history_limit else [] history = (
await run_in_threadpool(store.list_update_events_for_hostname, str(account["hostname"]), history_limit)
if history_limit
else []
)
return _hostname_status(service, account, history) return _hostname_status(service, account, history)
@@ -149,9 +157,9 @@ async def read_admin(
), ),
metrics=_admin_metrics(metrics), metrics=_admin_metrics(metrics),
hostnames=[_admin_hostname(account) for account in accounts], hostnames=[_admin_hostname(account) for account in accounts],
update_events=events, update_events=[_admin_update_event(event) for event in events],
cleanup_runs=cleanup_runs, cleanup_runs=[_admin_cleanup_run(cleanup_run) for cleanup_run in cleanup_runs],
scheduled_tasks=scheduled_tasks, scheduled_tasks=[_admin_scheduled_task(task) for task in scheduled_tasks],
limit=limit, limit=limit,
) )
@@ -204,7 +212,13 @@ async def list_admin_events(
) )
next_before = str(items[-1]["created_at"]) if len(items) == limit and items[-1].get("created_at") else None next_before = str(items[-1]["created_at"]) if len(items) == limit and items[-1].get("created_at") else None
total = await run_in_threadpool(store.count_update_events) total = await run_in_threadpool(store.count_update_events)
return AdminEventsResponse(items=items, limit=limit, offset=offset, next_before=next_before, total=total) return AdminEventsResponse(
items=[_admin_update_event(item) for item in items],
limit=limit,
offset=offset,
next_before=next_before,
total=total,
)
@router.get( @router.get(
@@ -224,4 +238,9 @@ async def list_admin_records(
) -> AdminRecordsResponse: ) -> AdminRecordsResponse:
items = await run_in_threadpool(store.list_records_page, limit, offset) items = await run_in_threadpool(store.list_records_page, limit, offset)
total = await run_in_threadpool(store.count_records) total = await run_in_threadpool(store.count_records)
return AdminRecordsResponse(items=items, limit=limit, offset=offset, total=total) return AdminRecordsResponse(
items=[_admin_record(item) for item in items],
limit=limit,
offset=offset,
total=total,
)
+23 -1
View File
@@ -14,7 +14,16 @@ ASSET_CACHE_HEADERS = {"Cache-Control": "public, max-age=31536000, immutable"}
def _minify_css(css: str) -> str: def _minify_css(css: str) -> str:
return " ".join(css.split()).replace(" {", "{").replace("{ ", "{").replace("; ", ";").replace(": ", ":").replace(", ", ",").replace(" }", "}").replace(";}", "}") return (
" ".join(css.split())
.replace(" {", "{")
.replace("{ ", "{")
.replace("; ", ";")
.replace(": ", ":")
.replace(", ", ",")
.replace(" }", "}")
.replace(";}", "}")
)
MINIFIED_BASE_CSS = _minify_css(BASE_CSS) MINIFIED_BASE_CSS = _minify_css(BASE_CSS)
@@ -25,50 +34,62 @@ MINIFIED_ADMIN_CSS = _minify_css(ADMIN_CSS)
def favicon() -> FileResponse: def favicon() -> FileResponse:
return FileResponse(ASSET_DIR / "favicon.ico", media_type="image/x-icon", headers=ASSET_CACHE_HEADERS) return FileResponse(ASSET_DIR / "favicon.ico", media_type="image/x-icon", headers=ASSET_CACHE_HEADERS)
@router.get("/bootstrap-subset.css") @router.get("/bootstrap-subset.css")
def bootstrap_subset_css() -> FileResponse: def bootstrap_subset_css() -> FileResponse:
return FileResponse(ASSET_DIR / "bootstrap-subset.css", media_type="text/css", headers=ASSET_CACHE_HEADERS) return FileResponse(ASSET_DIR / "bootstrap-subset.css", media_type="text/css", headers=ASSET_CACHE_HEADERS)
@router.get("/favicon-16x16.png") @router.get("/favicon-16x16.png")
def favicon_16_png() -> FileResponse: def favicon_16_png() -> FileResponse:
return FileResponse(ASSET_DIR / "favicon-16x16.png", media_type="image/png", headers=ASSET_CACHE_HEADERS) return FileResponse(ASSET_DIR / "favicon-16x16.png", media_type="image/png", headers=ASSET_CACHE_HEADERS)
@router.get("/favicon-32x32.png") @router.get("/favicon-32x32.png")
def favicon_32_png() -> FileResponse: def favicon_32_png() -> FileResponse:
return FileResponse(ASSET_DIR / "favicon-32x32.png", media_type="image/png", headers=ASSET_CACHE_HEADERS) return FileResponse(ASSET_DIR / "favicon-32x32.png", media_type="image/png", headers=ASSET_CACHE_HEADERS)
@router.get("/apple-touch-icon.png") @router.get("/apple-touch-icon.png")
def apple_touch_icon_png() -> FileResponse: def apple_touch_icon_png() -> FileResponse:
return FileResponse(ASSET_DIR / "apple-touch-icon.png", media_type="image/png", headers=ASSET_CACHE_HEADERS) return FileResponse(ASSET_DIR / "apple-touch-icon.png", media_type="image/png", headers=ASSET_CACHE_HEADERS)
@router.get("/logo.png") @router.get("/logo.png")
def logo_png() -> FileResponse: def logo_png() -> FileResponse:
return FileResponse(ASSET_DIR / "logo.png", media_type="image/png", headers=ASSET_CACHE_HEADERS) return FileResponse(ASSET_DIR / "logo.png", media_type="image/png", headers=ASSET_CACHE_HEADERS)
@router.get("/logo.webp") @router.get("/logo.webp")
def logo_webp() -> FileResponse: def logo_webp() -> FileResponse:
return FileResponse(ASSET_DIR / "logo.webp", media_type="image/webp", headers=ASSET_CACHE_HEADERS) return FileResponse(ASSET_DIR / "logo.webp", media_type="image/webp", headers=ASSET_CACHE_HEADERS)
@router.get("/logo-nav.webp") @router.get("/logo-nav.webp")
def logo_nav_webp() -> FileResponse: def logo_nav_webp() -> FileResponse:
return FileResponse(ASSET_DIR / "logo-nav.webp", media_type="image/webp", headers=ASSET_CACHE_HEADERS) return FileResponse(ASSET_DIR / "logo-nav.webp", media_type="image/webp", headers=ASSET_CACHE_HEADERS)
@router.get("/logo-192.png") @router.get("/logo-192.png")
def logo_192_png() -> FileResponse: def logo_192_png() -> FileResponse:
return FileResponse(ASSET_DIR / "logo-192.png", media_type="image/png", headers=ASSET_CACHE_HEADERS) return FileResponse(ASSET_DIR / "logo-192.png", media_type="image/png", headers=ASSET_CACHE_HEADERS)
@router.get("/logo-512.png") @router.get("/logo-512.png")
def logo_512_png() -> FileResponse: def logo_512_png() -> FileResponse:
return FileResponse(ASSET_DIR / "logo-512.png", media_type="image/png", headers=ASSET_CACHE_HEADERS) return FileResponse(ASSET_DIR / "logo-512.png", media_type="image/png", headers=ASSET_CACHE_HEADERS)
@router.get("/social-preview.png") @router.get("/social-preview.png")
def social_preview_png() -> FileResponse: def social_preview_png() -> FileResponse:
return FileResponse(ASSET_DIR / "social-preview.png", media_type="image/png", headers=ASSET_CACHE_HEADERS) return FileResponse(ASSET_DIR / "social-preview.png", media_type="image/png", headers=ASSET_CACHE_HEADERS)
@router.get("/social-preview.webp") @router.get("/social-preview.webp")
def social_preview_webp() -> FileResponse: def social_preview_webp() -> FileResponse:
return FileResponse(ASSET_DIR / "social-preview.webp", media_type="image/webp", headers=ASSET_CACHE_HEADERS) return FileResponse(ASSET_DIR / "social-preview.webp", media_type="image/webp", headers=ASSET_CACHE_HEADERS)
@router.get("/site.webmanifest") @router.get("/site.webmanifest")
def site_webmanifest() -> Response: def site_webmanifest() -> Response:
return Response( return Response(
@@ -92,6 +113,7 @@ def site_webmanifest() -> Response:
headers=ASSET_CACHE_HEADERS, headers=ASSET_CACHE_HEADERS,
) )
@router.get("/theme.js") @router.get("/theme.js")
def theme_js() -> Response: def theme_js() -> Response:
return Response(THEME_JS, media_type="application/javascript", headers=ASSET_CACHE_HEADERS) return Response(THEME_JS, media_type="application/javascript", headers=ASSET_CACHE_HEADERS)
+27 -14
View File
@@ -50,14 +50,24 @@ def manage_dashboard(
account = get_active_management_account(store, management_slug) account = get_active_management_account(store, management_slug)
csrf_token = dashboard_csrf_token(management_slug, account) csrf_token = dashboard_csrf_token(management_slug, account)
if not management_account_is_accepted(account): if not management_account_is_accepted(account):
return _render_pending_management_page(service, settings, management_slug, account, csrf_token, request_ip=client_ip(request, settings)) return _render_pending_management_page(
service, settings, management_slug, account, csrf_token, request_ip=client_ip(request, settings)
)
if not has_management_access(request, settings, management_slug, account): if not has_management_access(request, settings, management_slug, account):
return _render_management_unlock_page(management_slug, account, csrf_token) return _render_management_unlock_page(management_slug, account, csrf_token)
message = "Hostname registered. Add the update URL to your router." if activated else None message = "Hostname registered. Add the update URL to your router." if activated else None
return _render_management_page(service, settings, management_slug, account, csrf_token, message=message, request_ip=client_ip(request, settings)) return _render_management_page(
service,
settings,
management_slug,
account,
csrf_token,
message=message,
request_ip=client_ip(request, settings),
)
@router.post("/m/{management_slug}/activate", include_in_schema=False) @router.post("/m/{management_slug}/activate", include_in_schema=False, response_model=None)
async def activate_dashboard( async def activate_dashboard(
management_slug: ManagementSlugPath, management_slug: ManagementSlugPath,
request: Request, request: Request,
@@ -68,7 +78,7 @@ async def activate_dashboard(
ipaddr: str | None = None, ipaddr: str | None = None,
myipv6: str | None = None, myipv6: str | None = None,
ip6addr: str | None = None, ip6addr: str | None = None,
) -> RedirectResponse: ) -> RedirectResponse | HTMLResponse:
account = await get_active_management_account_async(store, management_slug) account = await get_active_management_account_async(store, management_slug)
form = await request_form(request, settings) form = await request_form(request, settings)
require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf")) require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf"))
@@ -102,8 +112,8 @@ async def activate_dashboard(
), ),
status_code=500, status_code=500,
) )
account = await run_in_threadpool(store.activate_account, hostname) activated_account = await run_in_threadpool(lambda: store.activate_account(hostname))
if not account: if not activated_account:
return _not_found() return _not_found()
await run_in_threadpool( await run_in_threadpool(
store.log_update_event, store.log_update_event,
@@ -117,14 +127,14 @@ async def activate_dashboard(
return RedirectResponse(f"/m/{management_slug}?activated=1", status_code=303) return RedirectResponse(f"/m/{management_slug}?activated=1", status_code=303)
@router.post("/m/{management_slug}/rotate", response_class=HTMLResponse, include_in_schema=False) @router.post("/m/{management_slug}/rotate", response_class=HTMLResponse, include_in_schema=False, response_model=None)
async def rotate_dashboard_secrets( async def rotate_dashboard_secrets(
management_slug: ManagementSlugPath, management_slug: ManagementSlugPath,
request: Request, request: Request,
settings: SettingsDep, settings: SettingsDep,
store: StoreDep, store: StoreDep,
service: ServiceDep, service: ServiceDep,
) -> str: ) -> str | HTMLResponse:
account = await get_active_management_account_async(store, management_slug) account = await get_active_management_account_async(store, management_slug)
if not management_account_is_accepted(account): if not management_account_is_accepted(account):
return HTMLResponse( return HTMLResponse(
@@ -172,10 +182,15 @@ async def rotate_dashboard_secrets(
@router.post("/m/{management_slug}/delete", include_in_schema=False) @router.post("/m/{management_slug}/delete", include_in_schema=False)
async def delete_dashboard_hostname(management_slug: ManagementSlugPath, request: Request, settings: SettingsDep, store: StoreDep, service: ServiceDep) -> Response: async def delete_dashboard_hostname(
management_slug: ManagementSlugPath, request: Request, settings: SettingsDep, store: StoreDep, service: ServiceDep
) -> Response:
account = await get_active_management_account_async(store, management_slug) account = await get_active_management_account_async(store, management_slug)
if not has_management_access(request, settings, management_slug, account): if not has_management_access(request, settings, management_slug, account):
return HTMLResponse(_render_management_unlock_page(management_slug, account, dashboard_csrf_token(management_slug, account)), status_code=401) return HTMLResponse(
_render_management_unlock_page(management_slug, account, dashboard_csrf_token(management_slug, account)),
status_code=401,
)
form = await request_form(request, settings) form = await request_form(request, settings)
require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf")) require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf"))
hostname = str(account["hostname"]) hostname = str(account["hostname"])
@@ -195,8 +210,7 @@ async def delete_dashboard_hostname(management_slug: ManagementSlugPath, request
return HTMLResponse( return HTMLResponse(
_page( _page(
"Hostname deleted", "Hostname deleted",
trusted( trusted("""
"""
<main> <main>
<section class="hero-band compact"> <section class="hero-band compact">
<div class="container text-center d-grid justify-items-center gap-3"> <div class="container text-center d-grid justify-items-center gap-3">
@@ -207,8 +221,7 @@ async def delete_dashboard_hostname(management_slug: ManagementSlugPath, request
</div> </div>
</section> </section>
</main> </main>
""" """),
),
) )
) )
+14 -4
View File
@@ -24,7 +24,9 @@ router = APIRouter()
@router.post("/m/{management_slug}/unlock", include_in_schema=False) @router.post("/m/{management_slug}/unlock", include_in_schema=False)
async def unlock_dashboard(management_slug: ManagementSlugPath, request: Request, settings: SettingsDep, store: StoreDep) -> Response: async def unlock_dashboard(
management_slug: ManagementSlugPath, request: Request, settings: SettingsDep, store: StoreDep
) -> Response:
account = await get_active_management_account_async(store, management_slug) account = await get_active_management_account_async(store, management_slug)
if not account.get("dashboard_password_hash"): if not account.get("dashboard_password_hash"):
return RedirectResponse(f"/m/{management_slug}", status_code=303) return RedirectResponse(f"/m/{management_slug}", status_code=303)
@@ -35,7 +37,9 @@ async def unlock_dashboard(management_slug: ManagementSlugPath, request: Request
is_valid = await run_in_threadpool(store.verify_dashboard_password, hostname, password) is_valid = await run_in_threadpool(store.verify_dashboard_password, hostname, password)
if not is_valid: if not is_valid:
return HTMLResponse( return HTMLResponse(
_render_management_unlock_page(management_slug, account, dashboard_csrf_token(management_slug, account), "Incorrect password."), _render_management_unlock_page(
management_slug, account, dashboard_csrf_token(management_slug, account), "Incorrect password."
),
status_code=401, status_code=401,
) )
response = RedirectResponse(f"/m/{management_slug}", status_code=303) response = RedirectResponse(f"/m/{management_slug}", status_code=303)
@@ -59,7 +63,10 @@ async def set_dashboard_password(
) -> Response: ) -> Response:
account = await get_active_management_account_async(store, management_slug) account = await get_active_management_account_async(store, management_slug)
if not has_management_access(request, settings, management_slug, account): if not has_management_access(request, settings, management_slug, account):
return HTMLResponse(_render_management_unlock_page(management_slug, account, dashboard_csrf_token(management_slug, account)), status_code=401) return HTMLResponse(
_render_management_unlock_page(management_slug, account, dashboard_csrf_token(management_slug, account)),
status_code=401,
)
form = await request_form(request, settings) form = await request_form(request, settings)
require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf")) require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf"))
hostname = str(account["hostname"]) hostname = str(account["hostname"])
@@ -129,7 +136,10 @@ async def remove_dashboard_password(
) -> Response: ) -> Response:
account = await get_active_management_account_async(store, management_slug) account = await get_active_management_account_async(store, management_slug)
if not has_management_access(request, settings, management_slug, account): if not has_management_access(request, settings, management_slug, account):
return HTMLResponse(_render_management_unlock_page(management_slug, account, dashboard_csrf_token(management_slug, account)), status_code=401) return HTMLResponse(
_render_management_unlock_page(management_slug, account, dashboard_csrf_token(management_slug, account)),
status_code=401,
)
form = await request_form(request, settings) form = await request_form(request, settings)
require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf")) require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf"))
hostname = str(account["hostname"]) hostname = str(account["hostname"])
+10 -3
View File
@@ -35,7 +35,9 @@ def management_account_is_accepted(account: ManagementAccount) -> bool:
return bool(account.get("accepted_at")) return bool(account.get("accepted_at"))
def has_management_access(request: Request, settings: DdnsSettings, management_slug: str, account: ManagementAccount) -> bool: def has_management_access(
request: Request, settings: DdnsSettings, management_slug: str, account: ManagementAccount
) -> bool:
password_hash = account.get("dashboard_password_hash") password_hash = account.get("dashboard_password_hash")
if not password_hash: if not password_hash:
return True return True
@@ -49,7 +51,10 @@ def has_management_access(request: Request, settings: DdnsSettings, management_s
def locked_management_response(management_slug: str, account: ManagementAccount) -> HTMLResponse: def locked_management_response(management_slug: str, account: ManagementAccount) -> HTMLResponse:
return HTMLResponse(_render_management_unlock_page(management_slug, account, dashboard_csrf_token(management_slug, account)), status_code=401) return HTMLResponse(
_render_management_unlock_page(management_slug, account, dashboard_csrf_token(management_slug, account)),
status_code=401,
)
def management_error_response( def management_error_response(
@@ -82,7 +87,9 @@ async def management_error_response_async(
message: str, message: str,
request_ip: str | None = None, request_ip: str | None = None,
) -> HTMLResponse: ) -> HTMLResponse:
return await run_in_threadpool(management_error_response, service, settings, management_slug, account, message, request_ip) return await run_in_threadpool(
management_error_response, service, settings, management_slug, account, message, request_ip
)
async def render_management_page_async( async def render_management_page_async(
+3 -1
View File
@@ -152,7 +152,9 @@ async def _update_hostname(
raise HTTPException(status_code=404, detail="not found") raise HTTPException(status_code=404, detail="not found")
if not service.hostname_is_managed(str(account["hostname"])): if not service.hostname_is_managed(str(account["hostname"])):
raise HTTPException(status_code=404, detail="not found") raise HTTPException(status_code=404, detail="not found")
return await run_in_threadpool(apply_update_response, service, request, str(account["hostname"]), myip, ipaddr, myipv6, ip6addr) return await run_in_threadpool(
apply_update_response, service, request, str(account["hostname"]), myip, ipaddr, myipv6, ip6addr
)
async def _ip_params_from_request( async def _ip_params_from_request(
+71 -9
View File
@@ -1,3 +1,5 @@
from collections.abc import Mapping, Sequence
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from .services.ddns import DdnsService from .services.ddns import DdnsService
@@ -12,7 +14,9 @@ class HostnameCreateResponse(BaseModel):
status: str = Field(description="Lifecycle state. Newly generated hostnames start as pending until registered.") status: str = Field(description="Lifecycle state. Newly generated hostnames start as pending until registered.")
update_url: str = Field(description="Secret update URL for router DynDNS updates.") update_url: str = Field(description="Secret update URL for router DynDNS updates.")
dashboard_url: str = Field(description="Secret dashboard URL for reading, rotating, or deleting this hostname.") dashboard_url: str = Field(description="Secret dashboard URL for reading, rotating, or deleting this hostname.")
registration_url: str = Field(description="Secret URL used to register the pending hostname after secrets are saved.") registration_url: str = Field(
description="Secret URL used to register the pending hostname after secrets are saved."
)
class HostnameStatusResponse(BaseModel): class HostnameStatusResponse(BaseModel):
@@ -134,9 +138,9 @@ class AdminEventsResponse(BaseModel):
total: int total: int
def _create_response(service: DdnsService, account: dict[str, str]) -> HostnameCreateResponse: def _create_response(service: DdnsService, account: Mapping[str, object]) -> HostnameCreateResponse:
return HostnameCreateResponse( return HostnameCreateResponse(
hostname=account["hostname"], hostname=str(account["hostname"]),
status="active" if account.get("accepted_at") else "pending", status="active" if account.get("accepted_at") else "pending",
update_url=service.fritz_update_url(account), update_url=service.fritz_update_url(account),
dashboard_url=service.dashboard_url(account), dashboard_url=service.dashboard_url(account),
@@ -146,8 +150,8 @@ def _create_response(service: DdnsService, account: dict[str, str]) -> HostnameC
def _hostname_status( def _hostname_status(
service: DdnsService, service: DdnsService,
account: dict[str, str | int | None], account: Mapping[str, object],
history: list[dict[str, str | int | None]] | None = None, history: Sequence[Mapping[str, object]] | None = None,
) -> HostnameStatusResponse: ) -> HostnameStatusResponse:
account_for_url = {"update_slug": str(account["update_slug"])} account_for_url = {"update_slug": str(account["update_slug"])}
account_for_dashboard = {"management_slug": str(account.get("management_slug", ""))} account_for_dashboard = {"management_slug": str(account.get("management_slug", ""))}
@@ -163,7 +167,9 @@ def _hostname_status(
) )
def _hostname_rotate_update_response(service: DdnsService, account: dict[str, str | int | None], dashboard_secret: str) -> HostnameRotateUpdateResponse: def _hostname_rotate_update_response(
service: DdnsService, account: Mapping[str, object], dashboard_secret: str
) -> HostnameRotateUpdateResponse:
return HostnameRotateUpdateResponse( return HostnameRotateUpdateResponse(
hostname=str(account["hostname"]), hostname=str(account["hostname"]),
status="active" if account.get("accepted_at") else "pending", status="active" if account.get("accepted_at") else "pending",
@@ -172,7 +178,7 @@ def _hostname_rotate_update_response(service: DdnsService, account: dict[str, st
) )
def _hostname_update_event(row: dict[str, str | int | None]) -> HostnameUpdateEvent: def _hostname_update_event(row: Mapping[str, object]) -> HostnameUpdateEvent:
return HostnameUpdateEvent( return HostnameUpdateEvent(
hostname=str(row["hostname"]), hostname=str(row["hostname"]),
ipv4=str(row["ipv4"]) if row.get("ipv4") else None, ipv4=str(row["ipv4"]) if row.get("ipv4") else None,
@@ -180,11 +186,11 @@ def _hostname_update_event(row: dict[str, str | int | None]) -> HostnameUpdateEv
status=str(row["status"]), status=str(row["status"]),
detail=str(row["detail"]), detail=str(row["detail"]),
created_at=str(row["created_at"]), created_at=str(row["created_at"]),
count=int(row.get("count") or 1), count=_int_value(row.get("count"), 1),
) )
def _admin_hostname(account: dict[str, str | int | None]) -> AdminHostname: def _admin_hostname(account: Mapping[str, object]) -> AdminHostname:
return AdminHostname( return AdminHostname(
hostname=str(account["hostname"]), hostname=str(account["hostname"]),
status="disabled" if account.get("disabled") else ("active" if account.get("accepted_at") else "pending"), status="disabled" if account.get("disabled") else ("active" if account.get("accepted_at") else "pending"),
@@ -201,3 +207,59 @@ def _admin_metrics(metrics: dict[str, float]) -> AdminMetrics:
samples = metrics.get("update_latency_samples_total", 0) samples = metrics.get("update_latency_samples_total", 0)
average_latency = metrics.get("update_latency_ms_total", 0) / samples if samples else 0 average_latency = metrics.get("update_latency_ms_total", 0) / samples if samples else 0
return AdminMetrics(values=metrics, average_update_latency_ms=average_latency) return AdminMetrics(values=metrics, average_update_latency_ms=average_latency)
def _admin_record(row: Mapping[str, object]) -> AdminRecord:
return AdminRecord(
hostname=str(row["hostname"]),
ipv4=str(row["ipv4"]) if row.get("ipv4") else None,
ipv6=str(row["ipv6"]) if row.get("ipv6") else None,
updated_at=str(row["updated_at"]),
)
def _admin_update_event(row: Mapping[str, object]) -> AdminUpdateEvent:
return AdminUpdateEvent(
hostname=str(row["hostname"]),
ipv4=str(row["ipv4"]) if row.get("ipv4") else None,
ipv6=str(row["ipv6"]) if row.get("ipv6") else None,
status=str(row["status"]),
detail=str(row["detail"]),
source_ip=str(row["source_ip"]),
created_at=str(row["created_at"]),
count=_int_value(row.get("count"), 1),
)
def _admin_cleanup_run(row: Mapping[str, object]) -> AdminCleanupRun:
return AdminCleanupRun(
created_at=str(row["created_at"]),
unused_accounts_deleted=_int_value(row["unused_accounts_deleted"], 0),
)
def _admin_scheduled_task(row: Mapping[str, object]) -> AdminScheduledTask:
return AdminScheduledTask(
name=str(row["name"]),
interval_seconds=_int_value(row["interval_seconds"], 0),
next_run_at=str(row["next_run_at"]),
lease_owner=str(row["lease_owner"]) if row.get("lease_owner") else None,
lease_expires_at=str(row["lease_expires_at"]) if row.get("lease_expires_at") else None,
last_started_at=str(row["last_started_at"]) if row.get("last_started_at") else None,
last_finished_at=str(row["last_finished_at"]) if row.get("last_finished_at") else None,
last_status=str(row["last_status"]),
last_error=str(row["last_error"]),
updated_at=str(row["updated_at"]),
)
def _int_value(value: object, default: int) -> int:
if value is None:
return default
if isinstance(value, int):
return value
if isinstance(value, float):
return int(value)
if isinstance(value, str):
return int(value)
return default
+3 -2
View File
@@ -5,6 +5,7 @@ import hashlib
import hmac import hmac
import secrets import secrets
import time import time
from collections.abc import Mapping
from datetime import UTC, datetime from datetime import UTC, datetime
from ipaddress import ip_address, ip_network from ipaddress import ip_address, ip_network
@@ -135,7 +136,7 @@ def dashboard_cookie_signing_secret(settings: DdnsSettings) -> str:
return settings.dashboard_cookie_secret or settings.admin_password return settings.dashboard_cookie_secret or settings.admin_password
def dashboard_csrf_token(management_slug: str, account: dict[str, str | int | None]) -> str: def dashboard_csrf_token(management_slug: str, account: Mapping[str, object]) -> str:
hostname = str(account["hostname"]) hostname = str(account["hostname"])
password_hash = str(account.get("dashboard_password_hash") or "") password_hash = str(account.get("dashboard_password_hash") or "")
day = datetime.now(UTC).strftime("%Y-%m-%d") day = datetime.now(UTC).strftime("%Y-%m-%d")
@@ -143,7 +144,7 @@ def dashboard_csrf_token(management_slug: str, account: dict[str, str | int | No
return hmac.new(key, f"dashboard:{hostname}:{day}".encode(), hashlib.sha256).hexdigest() return hmac.new(key, f"dashboard:{hostname}:{day}".encode(), hashlib.sha256).hexdigest()
def require_dashboard_csrf(management_slug: str, account: dict[str, str | int | None], supplied: str) -> None: def require_dashboard_csrf(management_slug: str, account: Mapping[str, object], supplied: str) -> None:
if not supplied or not hmac.compare_digest(supplied, dashboard_csrf_token(management_slug, account)): if not supplied or not hmac.compare_digest(supplied, dashboard_csrf_token(management_slug, account)):
raise HTTPException(status_code=403, detail="invalid csrf token") raise HTTPException(status_code=403, detail="invalid csrf token")
+23 -11
View File
@@ -8,6 +8,7 @@ import sqlite3
import threading import threading
import time import time
import urllib.parse import urllib.parse
from collections.abc import Mapping
from dataclasses import dataclass from dataclasses import dataclass
from fastapi import HTTPException, Request from fastapi import HTTPException, Request
@@ -18,6 +19,7 @@ from ..models import UpdateResult
from ..security import client_ip from ..security import client_ip
from ..settings import DdnsSettings from ..settings import DdnsSettings
from ..store import DdnsStore from ..store import DdnsStore
from ..store.types import AccountRow
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
INITIAL_PLACEHOLDER_IPV4 = "192.0.2.1" INITIAL_PLACEHOLDER_IPV4 = "192.0.2.1"
@@ -41,7 +43,7 @@ class DdnsService:
self.store = store self.store = store
self._locks = [threading.Lock() for _ in range(self._LOCK_STRIPES)] self._locks = [threading.Lock() for _ in range(self._LOCK_STRIPES)]
def create_managed_account(self) -> dict[str, str]: def create_managed_account(self) -> AccountRow:
if not self.settings.hostname_suffix: if not self.settings.hostname_suffix:
raise HTTPException(status_code=500, detail="DDNS_HOSTNAME_SUFFIX is required") raise HTTPException(status_code=500, detail="DDNS_HOSTNAME_SUFFIX is required")
for _ in range(5): for _ in range(5):
@@ -79,6 +81,8 @@ class DdnsService:
source_ip = client_ip(request, self.settings) source_ip = client_ip(request, self.settings)
source_fallback = source_ip if _is_global_ip(source_ip) else "" source_fallback = source_ip if _is_global_ip(source_ip) else ""
supplied_ip = any(value and value.strip() for value in (myip, ipaddr, myipv6, ip6addr)) supplied_ip = any(value and value.strip() for value in (myip, ipaddr, myipv6, ip6addr))
ipv4: str | None
ipv6: str | None
if not supplied_ip and not source_fallback: if not supplied_ip and not source_fallback:
ipv4 = INITIAL_PLACEHOLDER_IPV4 ipv4 = INITIAL_PLACEHOLDER_IPV4
ipv6 = None ipv6 = None
@@ -108,7 +112,9 @@ class DdnsService:
planned.update_ipv6, planned.update_ipv6,
type(exc).__name__, type(exc).__name__,
) )
self.store.log_update_event(hostname, ipv4, ipv6, DNS_RECONCILE_FAILED, f"{type(exc).__name__}: {exc}", source_ip) self.store.log_update_event(
hostname, ipv4, ipv6, DNS_RECONCILE_FAILED, f"{type(exc).__name__}: {exc}", source_ip
)
raise raise
except DnsProviderError as exc: except DnsProviderError as exc:
LOGGER.warning( LOGGER.warning(
@@ -118,7 +124,9 @@ class DdnsService:
planned.update_ipv6, planned.update_ipv6,
type(exc).__name__, type(exc).__name__,
) )
self.store.log_update_event(hostname, ipv4, ipv6, DNS_FAILED, f"{type(exc).__name__}: {exc}", source_ip) self.store.log_update_event(
hostname, ipv4, ipv6, DNS_FAILED, f"{type(exc).__name__}: {exc}", source_ip
)
raise raise
result = self.store.commit_update_result(planned) result = self.store.commit_update_result(planned)
self.store.log_update_event(hostname, result.ipv4, result.ipv6, UPDATED, detail, source_ip) self.store.log_update_event(hostname, result.ipv4, result.ipv6, UPDATED, detail, source_ip)
@@ -149,7 +157,9 @@ class DdnsService:
) )
lock = self._lock_for(hostname) lock = self._lock_for(hostname)
with lock: with lock:
planned = self.store.preview_update(hostname, ipv4, ipv6, update_ipv4=update_ipv4, update_ipv6=update_ipv6) planned = self.store.preview_update(
hostname, ipv4, ipv6, update_ipv4=update_ipv4, update_ipv6=update_ipv6
)
if planned.changed: if planned.changed:
try: try:
publish_dns(planned, self.settings) publish_dns(planned, self.settings)
@@ -180,7 +190,9 @@ class DdnsService:
planned.update_ipv6, planned.update_ipv6,
type(exc).__name__, type(exc).__name__,
) )
self.store.log_update_event(hostname, ipv4, ipv6, DNS_FAILED, f"{type(exc).__name__}: {exc}", source_ip) self.store.log_update_event(
hostname, ipv4, ipv6, DNS_FAILED, f"{type(exc).__name__}: {exc}", source_ip
)
raise raise
result = self.store.commit_update_result(planned) result = self.store.commit_update_result(planned)
is_success = True is_success = True
@@ -203,19 +215,19 @@ class DdnsService:
is_bad_ip=is_bad_ip, is_bad_ip=is_bad_ip,
) )
def fritz_update_url(self, account: dict[str, str]) -> str: def fritz_update_url(self, account: Mapping[str, object]) -> str:
base = self.settings.public_base_url.rstrip("/") base = self.settings.public_base_url.rstrip("/")
slug = urllib.parse.quote(account["update_slug"]) slug = urllib.parse.quote(str(account["update_slug"]))
return f"{base}/api/v1/router/update/{slug}?myip=<ipaddr>&myipv6=<ip6addr>" return f"{base}/api/v1/router/update/{slug}?myip=<ipaddr>&myipv6=<ip6addr>"
def dashboard_url(self, account: dict[str, str]) -> str: def dashboard_url(self, account: Mapping[str, object]) -> str:
base = self.settings.public_base_url.rstrip("/") base = self.settings.public_base_url.rstrip("/")
slug = urllib.parse.quote(account["management_slug"]) slug = urllib.parse.quote(str(account["management_slug"]))
return f"{base}/m/{slug}" return f"{base}/m/{slug}"
def registration_url(self, account: dict[str, str]) -> str: def registration_url(self, account: Mapping[str, object]) -> str:
base = self.settings.public_base_url.rstrip("/") base = self.settings.public_base_url.rstrip("/")
slug = urllib.parse.quote(account["management_slug"]) slug = urllib.parse.quote(str(account["management_slug"]))
return f"{base}/api/v1/router/register/{slug}" return f"{base}/api/v1/router/register/{slug}"
def random_managed_hostname(self) -> str: def random_managed_hostname(self) -> str:
+2 -1
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import threading import threading
from collections.abc import Iterator
from contextlib import contextmanager from contextlib import contextmanager
from pathlib import Path from pathlib import Path
from sqlite3 import Connection from sqlite3 import Connection
@@ -31,7 +32,7 @@ class DdnsStore(AccountsMixin, MetricsMixin, RecordsEventsMixin, RateLimitCleanu
self._init_db() self._init_db()
@contextmanager @contextmanager
def connect(self): def connect(self) -> Iterator[Connection]:
conn = self._thread_connection() conn = self._thread_connection()
try: try:
yield conn yield conn
+7 -6
View File
@@ -3,10 +3,11 @@ from __future__ import annotations
from datetime import UTC, datetime from datetime import UTC, datetime
from ..security import hash_lookup_token, hash_secret, new_bearer_secret, verify_secret from ..security import hash_lookup_token, hash_secret, new_bearer_secret, verify_secret
from .types import AccountRow from .base import StoreConnectionMixin
from .types import AccountRow, account_row
class AccountsMixin: class AccountsMixin(StoreConnectionMixin):
def create_account(self, hostname: str, username: str | None = None, *, accepted: bool = True) -> AccountRow: def create_account(self, hostname: str, username: str | None = None, *, accepted: bool = True) -> AccountRow:
now = datetime.now(UTC).isoformat() now = datetime.now(UTC).isoformat()
update_slug = new_bearer_secret() update_slug = new_bearer_secret()
@@ -41,7 +42,7 @@ class AccountsMixin:
""", """,
(limit, offset), (limit, offset),
).fetchall() ).fetchall()
return [dict(row) for row in rows] return [account_row(row) for row in rows]
def count_accounts(self) -> int: def count_accounts(self) -> int:
with self.connect() as conn: with self.connect() as conn:
@@ -60,7 +61,7 @@ class AccountsMixin:
""", """,
(hostname,), (hostname,),
).fetchone() ).fetchone()
return dict(account) if account else None return account_row(account) if account else None
def get_account_by_slug(self, update_slug: str) -> AccountRow | None: def get_account_by_slug(self, update_slug: str) -> AccountRow | None:
with self.connect() as conn: with self.connect() as conn:
@@ -71,7 +72,7 @@ class AccountsMixin:
""", """,
(update_slug,), (update_slug,),
).fetchone() ).fetchone()
return dict(account) if account else None return account_row(account) if account else None
def get_account_by_management_slug(self, management_slug: str) -> AccountRow | None: def get_account_by_management_slug(self, management_slug: str) -> AccountRow | None:
with self.connect() as conn: with self.connect() as conn:
@@ -85,7 +86,7 @@ class AccountsMixin:
""", """,
(hash_lookup_token(management_slug),), (hash_lookup_token(management_slug),),
).fetchone() ).fetchone()
return dict(account) if account else None return account_row(account) if account else None
def activate_account(self, hostname: str) -> AccountRow | None: def activate_account(self, hostname: str) -> AccountRow | None:
now = datetime.now(UTC).isoformat() now = datetime.now(UTC).isoformat()
+9
View File
@@ -0,0 +1,9 @@
from __future__ import annotations
import sqlite3
from contextlib import AbstractContextManager
class StoreConnectionMixin:
def connect(self) -> AbstractContextManager[sqlite3.Connection]:
raise NotImplementedError
+3 -1
View File
@@ -2,8 +2,10 @@ from __future__ import annotations
from datetime import UTC, datetime from datetime import UTC, datetime
from .base import StoreConnectionMixin
class MetricsMixin:
class MetricsMixin(StoreConnectionMixin):
def increment_metric(self, name: str, amount: float = 1.0) -> None: def increment_metric(self, name: str, amount: float = 1.0) -> None:
now = datetime.now(UTC).isoformat() now = datetime.now(UTC).isoformat()
with self.connect() as conn: with self.connect() as conn:
+12 -3
View File
@@ -1,14 +1,23 @@
from __future__ import annotations from __future__ import annotations
import threading
import time import time
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from .types import CleanupRunRow from .base import StoreConnectionMixin
from .types import CleanupRunRow, cleanup_run_row
RATE_LIMIT_FLUSH_SECONDS = 10.0 RATE_LIMIT_FLUSH_SECONDS = 10.0
class RateLimitCleanupMixin: class RateLimitCleanupMixin(StoreConnectionMixin):
_rate_limit_lock: threading.Lock
_rate_limit_prune_lock: threading.Lock
_rate_limit_buckets: dict[str, dict[str, float | int]]
_rate_limit_dirty_counts: dict[tuple[str, int], int]
_last_rate_limit_flush: float
_last_rate_limit_prune_window: int
def list_cleanup_runs(self, limit: int = 20, offset: int = 0) -> list[CleanupRunRow]: def list_cleanup_runs(self, limit: int = 20, offset: int = 0) -> list[CleanupRunRow]:
with self.connect() as conn: with self.connect() as conn:
rows = conn.execute( rows = conn.execute(
@@ -20,7 +29,7 @@ class RateLimitCleanupMixin:
""", """,
(limit, offset), (limit, offset),
).fetchall() ).fetchall()
return [CleanupRunRow(**dict(row)) for row in rows] return [cleanup_run_row(row) for row in rows]
def count_cleanup_runs(self) -> int: def count_cleanup_runs(self) -> int:
with self.connect() as conn: with self.connect() as conn:
+10 -9
View File
@@ -4,10 +4,11 @@ import sqlite3
from datetime import UTC, datetime from datetime import UTC, datetime
from ..models import UpdateResult from ..models import UpdateResult
from .types import UpdateEventRow from .base import StoreConnectionMixin
from .types import RecordRow, UpdateEventRow, record_row, update_event_row
class RecordsEventsMixin: class RecordsEventsMixin(StoreConnectionMixin):
def upsert( def upsert(
self, self,
hostname: str, hostname: str,
@@ -84,10 +85,10 @@ class RecordsEventsMixin:
update_ipv6=update_ipv6, update_ipv6=update_ipv6,
) )
def list_records(self) -> list[dict[str, str | None]]: def list_records(self) -> list[RecordRow]:
return self.list_records_page(limit=10_000, offset=0) return self.list_records_page(limit=10_000, offset=0)
def list_records_page(self, limit: int = 100, offset: int = 0) -> list[dict[str, str | None]]: def list_records_page(self, limit: int = 100, offset: int = 0) -> list[RecordRow]:
with self.connect() as conn: with self.connect() as conn:
rows = conn.execute( rows = conn.execute(
""" """
@@ -98,7 +99,7 @@ class RecordsEventsMixin:
""", """,
(limit, offset), (limit, offset),
).fetchall() ).fetchall()
return [dict(row) for row in rows] return [record_row(row) for row in rows]
def count_records(self) -> int: def count_records(self) -> int:
with self.connect() as conn: with self.connect() as conn:
@@ -167,7 +168,7 @@ class RecordsEventsMixin:
""", """,
(limit, offset), (limit, offset),
).fetchall() ).fetchall()
return [dict(row) for row in rows] return [update_event_row(row) for row in rows]
def list_update_events_before(self, limit: int = 100, before: str | None = None) -> list[UpdateEventRow]: def list_update_events_before(self, limit: int = 100, before: str | None = None) -> list[UpdateEventRow]:
where_clause = "WHERE created_at < ?" if before else "" where_clause = "WHERE created_at < ?" if before else ""
@@ -189,7 +190,7 @@ class RecordsEventsMixin:
""", """,
params, params,
).fetchall() ).fetchall()
return [dict(row) for row in rows] return [update_event_row(row) for row in rows]
def count_update_events(self) -> int: def count_update_events(self) -> int:
with self.connect() as conn: with self.connect() as conn:
@@ -228,7 +229,7 @@ def _merge_event_rows(
limit: int, limit: int,
offset: int, offset: int,
) -> list[UpdateEventRow]: ) -> list[UpdateEventRow]:
rows = [dict(row) for row in event_rows] rows = [update_event_row(row) for row in event_rows]
rows.extend(dict(row) for row in rollup_rows) rows.extend(update_event_row(row) for row in rollup_rows)
rows.sort(key=lambda row: str(row["created_at"]), reverse=True) rows.sort(key=lambda row: str(row["created_at"]), reverse=True)
return rows[offset : offset + limit] return rows[offset : offset + limit]
+6 -7
View File
@@ -2,7 +2,8 @@ from __future__ import annotations
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from .types import ScheduledTaskRow from .base import StoreConnectionMixin
from .types import ScheduledTaskRow, scheduled_task_row
def _now() -> datetime: def _now() -> datetime:
@@ -13,7 +14,7 @@ def _iso(value: datetime) -> str:
return value.isoformat() return value.isoformat()
class SchedulerMixin: class SchedulerMixin(StoreConnectionMixin):
def ensure_scheduled_task(self, name: str, interval_seconds: int) -> None: def ensure_scheduled_task(self, name: str, interval_seconds: int) -> None:
now = _now() now = _now()
with self.connect() as conn: with self.connect() as conn:
@@ -86,12 +87,10 @@ class SchedulerMixin:
def list_scheduled_tasks(self) -> list[ScheduledTaskRow]: def list_scheduled_tasks(self) -> list[ScheduledTaskRow]:
with self.connect() as conn: with self.connect() as conn:
rows = conn.execute( rows = conn.execute("""
"""
SELECT name, interval_seconds, next_run_at, lease_owner, lease_expires_at, SELECT name, interval_seconds, next_run_at, lease_owner, lease_expires_at,
last_started_at, last_finished_at, last_status, last_error, updated_at last_started_at, last_finished_at, last_status, last_error, updated_at
FROM scheduled_tasks FROM scheduled_tasks
ORDER BY name ORDER BY name
""" """).fetchall()
).fetchall() return [scheduled_task_row(row) for row in rows]
return [ScheduledTaskRow(**dict(row)) for row in rows]
+32 -56
View File
@@ -35,18 +35,15 @@ def initialize_schema(conn: sqlite3.Connection) -> None:
configure_connection(conn) configure_connection(conn)
schema_version = conn.execute("PRAGMA user_version").fetchone()[0] schema_version = conn.execute("PRAGMA user_version").fetchone()[0]
conn.execute("PRAGMA journal_mode = WAL") conn.execute("PRAGMA journal_mode = WAL")
conn.execute( conn.execute("""
"""
CREATE TABLE IF NOT EXISTS records ( CREATE TABLE IF NOT EXISTS records (
hostname TEXT PRIMARY KEY, hostname TEXT PRIMARY KEY,
ipv4 TEXT, ipv4 TEXT,
ipv6 TEXT, ipv6 TEXT,
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
) )
""" """)
) conn.execute("""
conn.execute(
"""
CREATE TABLE IF NOT EXISTS update_events ( CREATE TABLE IF NOT EXISTS update_events (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
hostname TEXT NOT NULL, hostname TEXT NOT NULL,
@@ -57,12 +54,12 @@ def initialize_schema(conn: sqlite3.Connection) -> None:
source_ip TEXT NOT NULL, source_ip TEXT NOT NULL,
created_at TEXT NOT NULL created_at TEXT NOT NULL
) )
""" """)
)
conn.execute("CREATE INDEX IF NOT EXISTS idx_update_events_created_at ON update_events(created_at)") conn.execute("CREATE INDEX IF NOT EXISTS idx_update_events_created_at ON update_events(created_at)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_update_events_hostname_created_at ON update_events(hostname, created_at DESC)")
conn.execute( conn.execute(
""" "CREATE INDEX IF NOT EXISTS idx_update_events_hostname_created_at ON update_events(hostname, created_at DESC)"
)
conn.execute("""
CREATE TABLE IF NOT EXISTS update_event_rollups ( CREATE TABLE IF NOT EXISTS update_event_rollups (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
hostname TEXT NOT NULL, hostname TEXT NOT NULL,
@@ -76,28 +73,24 @@ def initialize_schema(conn: sqlite3.Connection) -> None:
count INTEGER NOT NULL, count INTEGER NOT NULL,
UNIQUE(hostname, ipv4, ipv6, status, detail, source_ip) UNIQUE(hostname, ipv4, ipv6, status, detail, source_ip)
) )
""" """)
)
conn.execute("CREATE INDEX IF NOT EXISTS idx_update_event_rollups_last_seen_at ON update_event_rollups(last_seen_at)")
conn.execute( conn.execute(
""" "CREATE INDEX IF NOT EXISTS idx_update_event_rollups_last_seen_at ON update_event_rollups(last_seen_at)"
)
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_update_event_rollups_hostname_last_seen_at CREATE INDEX IF NOT EXISTS idx_update_event_rollups_hostname_last_seen_at
ON update_event_rollups(hostname, last_seen_at DESC) ON update_event_rollups(hostname, last_seen_at DESC)
""" """)
) conn.execute("""
conn.execute(
"""
CREATE TABLE IF NOT EXISTS cleanup_runs ( CREATE TABLE IF NOT EXISTS cleanup_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
unused_accounts_deleted INTEGER NOT NULL unused_accounts_deleted INTEGER NOT NULL
) )
""" """)
)
_migrate_cleanup_runs_schema(conn) _migrate_cleanup_runs_schema(conn)
conn.execute("CREATE INDEX IF NOT EXISTS idx_cleanup_runs_created_at ON cleanup_runs(created_at)") conn.execute("CREATE INDEX IF NOT EXISTS idx_cleanup_runs_created_at ON cleanup_runs(created_at)")
conn.execute( conn.execute("""
"""
CREATE TABLE IF NOT EXISTS scheduled_tasks ( CREATE TABLE IF NOT EXISTS scheduled_tasks (
name TEXT PRIMARY KEY, name TEXT PRIMARY KEY,
interval_seconds INTEGER NOT NULL, interval_seconds INTEGER NOT NULL,
@@ -110,37 +103,29 @@ def initialize_schema(conn: sqlite3.Connection) -> None:
last_error TEXT NOT NULL DEFAULT '', last_error TEXT NOT NULL DEFAULT '',
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
) )
""" """)
)
conn.execute("CREATE INDEX IF NOT EXISTS idx_scheduled_tasks_next_run_at ON scheduled_tasks(next_run_at)") conn.execute("CREATE INDEX IF NOT EXISTS idx_scheduled_tasks_next_run_at ON scheduled_tasks(next_run_at)")
conn.execute( conn.execute("""
"""
CREATE INDEX IF NOT EXISTS idx_scheduled_tasks_due CREATE INDEX IF NOT EXISTS idx_scheduled_tasks_due
ON scheduled_tasks(next_run_at, lease_expires_at) ON scheduled_tasks(next_run_at, lease_expires_at)
""" """)
) conn.execute("""
conn.execute(
"""
CREATE TABLE IF NOT EXISTS rate_limits ( CREATE TABLE IF NOT EXISTS rate_limits (
key TEXT NOT NULL, key TEXT NOT NULL,
window INTEGER NOT NULL, window INTEGER NOT NULL,
count INTEGER NOT NULL, count INTEGER NOT NULL,
PRIMARY KEY(key, window) PRIMARY KEY(key, window)
) )
""" """)
)
conn.execute("CREATE INDEX IF NOT EXISTS idx_rate_limits_window ON rate_limits(window)") conn.execute("CREATE INDEX IF NOT EXISTS idx_rate_limits_window ON rate_limits(window)")
conn.execute( conn.execute("""
"""
CREATE TABLE IF NOT EXISTS service_metrics ( CREATE TABLE IF NOT EXISTS service_metrics (
name TEXT PRIMARY KEY, name TEXT PRIMARY KEY,
value REAL NOT NULL, value REAL NOT NULL,
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
) )
""" """)
) conn.execute("""
conn.execute(
"""
CREATE TABLE IF NOT EXISTS accounts ( CREATE TABLE IF NOT EXISTS accounts (
hostname TEXT PRIMARY KEY, hostname TEXT PRIMARY KEY,
update_slug TEXT NOT NULL UNIQUE, update_slug TEXT NOT NULL UNIQUE,
@@ -150,8 +135,7 @@ def initialize_schema(conn: sqlite3.Connection) -> None:
accepted_at TEXT, accepted_at TEXT,
disabled INTEGER NOT NULL DEFAULT 0 disabled INTEGER NOT NULL DEFAULT 0
) )
""" """)
)
_ensure_column(conn, "accounts", "update_slug", "TEXT") _ensure_column(conn, "accounts", "update_slug", "TEXT")
_ensure_column(conn, "accounts", "management_hash", "TEXT") _ensure_column(conn, "accounts", "management_hash", "TEXT")
_ensure_column(conn, "accounts", "dashboard_password_hash", "TEXT") _ensure_column(conn, "accounts", "dashboard_password_hash", "TEXT")
@@ -190,8 +174,7 @@ def _migrate_accounts_schema(conn: sqlite3.Connection) -> None:
conn.execute("DROP INDEX IF EXISTS idx_accounts_accepted_at") conn.execute("DROP INDEX IF EXISTS idx_accounts_accepted_at")
conn.execute("DROP INDEX IF EXISTS idx_accounts_created_at") conn.execute("DROP INDEX IF EXISTS idx_accounts_created_at")
conn.execute("DROP INDEX IF EXISTS idx_accounts_hostname_username") conn.execute("DROP INDEX IF EXISTS idx_accounts_hostname_username")
conn.execute( conn.execute("""
"""
CREATE TABLE accounts_new ( CREATE TABLE accounts_new (
hostname TEXT PRIMARY KEY, hostname TEXT PRIMARY KEY,
update_slug TEXT NOT NULL UNIQUE, update_slug TEXT NOT NULL UNIQUE,
@@ -201,15 +184,12 @@ def _migrate_accounts_schema(conn: sqlite3.Connection) -> None:
accepted_at TEXT, accepted_at TEXT,
disabled INTEGER NOT NULL DEFAULT 0 disabled INTEGER NOT NULL DEFAULT 0
) )
""" """)
) conn.execute("""
conn.execute(
"""
INSERT INTO accounts_new(hostname, update_slug, management_hash, dashboard_password_hash, created_at, accepted_at, disabled) INSERT INTO accounts_new(hostname, update_slug, management_hash, dashboard_password_hash, created_at, accepted_at, disabled)
SELECT hostname, update_slug, management_hash, dashboard_password_hash, created_at, accepted_at, disabled SELECT hostname, update_slug, management_hash, dashboard_password_hash, created_at, accepted_at, disabled
FROM accounts FROM accounts
""" """)
)
conn.execute("DROP TABLE accounts") conn.execute("DROP TABLE accounts")
conn.execute("ALTER TABLE accounts_new RENAME TO accounts") conn.execute("ALTER TABLE accounts_new RENAME TO accounts")
@@ -219,21 +199,17 @@ def _migrate_cleanup_runs_schema(conn: sqlite3.Connection) -> None:
if columns <= {"id", "created_at", "unused_accounts_deleted"}: if columns <= {"id", "created_at", "unused_accounts_deleted"}:
return return
conn.execute("DROP INDEX IF EXISTS idx_cleanup_runs_created_at") conn.execute("DROP INDEX IF EXISTS idx_cleanup_runs_created_at")
conn.execute( conn.execute("""
"""
CREATE TABLE cleanup_runs_new ( CREATE TABLE cleanup_runs_new (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
unused_accounts_deleted INTEGER NOT NULL unused_accounts_deleted INTEGER NOT NULL
) )
""" """)
) conn.execute("""
conn.execute(
"""
INSERT INTO cleanup_runs_new(id, created_at, unused_accounts_deleted) INSERT INTO cleanup_runs_new(id, created_at, unused_accounts_deleted)
SELECT id, created_at, unused_accounts_deleted SELECT id, created_at, unused_accounts_deleted
FROM cleanup_runs FROM cleanup_runs
""" """)
)
conn.execute("DROP TABLE cleanup_runs") conn.execute("DROP TABLE cleanup_runs")
conn.execute("ALTER TABLE cleanup_runs_new RENAME TO cleanup_runs") conn.execute("ALTER TABLE cleanup_runs_new RENAME TO cleanup_runs")
+28 -1
View File
@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from typing import TypedDict from typing import Any, TypedDict, cast
class AccountRow(TypedDict, total=False): class AccountRow(TypedDict, total=False):
@@ -43,3 +43,30 @@ class ScheduledTaskRow(TypedDict, total=False):
last_status: str last_status: str
last_error: str last_error: str
updated_at: str updated_at: str
class RecordRow(TypedDict):
hostname: str
ipv4: str | None
ipv6: str | None
updated_at: str
def account_row(row: Any) -> AccountRow:
return cast(AccountRow, dict(row))
def cleanup_run_row(row: Any) -> CleanupRunRow:
return cast(CleanupRunRow, dict(row))
def record_row(row: Any) -> RecordRow:
return cast(RecordRow, dict(row))
def scheduled_task_row(row: Any) -> ScheduledTaskRow:
return cast(ScheduledTaskRow, dict(row))
def update_event_row(row: Any) -> UpdateEventRow:
return cast(UpdateEventRow, dict(row))
+18 -26
View File
@@ -46,8 +46,7 @@ def _copy_row(label: str, value: str, sensitivity: str | None = None, *, mask_se
else "" else ""
) )
row_class = "copy-field copy-field-secret" if is_secret else "copy-field copy-field-public" row_class = "copy-field copy-field-secret" if is_secret else "copy-field copy-field-public"
return trusted( return trusted(f"""
f"""
<div class="{row_class}"> <div class="{row_class}">
<div class="copy-pill"> <div class="copy-pill">
<span class="copy-pill-label">{safe_label}</span> <span class="copy-pill-label">{safe_label}</span>
@@ -58,8 +57,7 @@ def _copy_row(label: str, value: str, sensitivity: str | None = None, *, mask_se
{_svg_icon("clipboard")}<span data-copy-label>Copy</span>{_svg_icon("check", "check-icon")} {_svg_icon("clipboard")}<span data-copy-label>Copy</span>{_svg_icon("check", "check-icon")}
</button> </button>
</div> </div>
""" """)
)
def _copy_button(label: str, value: str, *, style: str = "outline-primary") -> SafeHtml: def _copy_button(label: str, value: str, *, style: str = "outline-primary") -> SafeHtml:
@@ -89,8 +87,7 @@ def _copy_menu(label: str, items: list[tuple[str, str]]) -> SafeHtml:
f'<button type="button" class="copy-menu-item" data-copy="{attr(item_value)}" aria-label="Copy {attr(item_label)}">' f'<button type="button" class="copy-menu-item" data-copy="{attr(item_value)}" aria-label="Copy {attr(item_label)}">'
f'{_svg_icon("clipboard")}<span data-copy-label>{escape(item_label)}</span>{_svg_icon("check", "check-icon")}</button>' f'{_svg_icon("clipboard")}<span data-copy-label>{escape(item_label)}</span>{_svg_icon("check", "check-icon")}</button>'
) )
return trusted( return trusted(f"""
f"""
<details class="copy-menu"> <details class="copy-menu">
<summary aria-label="{safe_label}" title="{safe_label}"> <summary aria-label="{safe_label}" title="{safe_label}">
{_svg_icon("more", "more-icon")} {_svg_icon("more", "more-icon")}
@@ -99,22 +96,21 @@ def _copy_menu(label: str, items: list[tuple[str, str]]) -> SafeHtml:
{''.join(buttons)} {''.join(buttons)}
</div> </div>
</details> </details>
""" """)
)
def _code_window(commands: str, *, copy_label: str = "code", class_name: str = "", copy_value: str | None = None) -> SafeHtml: def _code_window(
commands: str, *, copy_label: str = "code", class_name: str = "", copy_value: str | None = None
) -> SafeHtml:
safe_class = f" {attr(class_name)}" if class_name else "" safe_class = f" {attr(class_name)}" if class_name else ""
code_lines = "\n".join(f"<code>{escape(line)}</code>" for line in commands.splitlines()) code_lines = "\n".join(f"<code>{escape(line)}</code>" for line in commands.splitlines())
copy_value = copy_value if copy_value is not None else _code_copy_value(commands) copy_value = copy_value if copy_value is not None else _code_copy_value(commands)
return trusted( return trusted(f"""
f"""
<div class="terminal-card code-window{safe_class}"> <div class="terminal-card code-window{safe_class}">
{_copy_icon_button(copy_label, copy_value)} {_copy_icon_button(copy_label, copy_value)}
{code_lines} {code_lines}
</div> </div>
""" """)
)
def _code_copy_value(commands: str) -> str: def _code_copy_value(commands: str) -> str:
@@ -167,8 +163,7 @@ def _known_request_ip(request_ip: str | None = None) -> tuple[int | None, str]:
def _manual_update_panel(commands: str) -> SafeHtml: def _manual_update_panel(commands: str) -> SafeHtml:
return trusted( return trusted(f"""
f"""
<div class="card card-body gap-3 mt-4"> <div class="card card-body gap-3 mt-4">
<div> <div>
<h3>Manual updates</h3> <h3>Manual updates</h3>
@@ -176,8 +171,7 @@ def _manual_update_panel(commands: str) -> SafeHtml:
</div> </div>
{_code_window(commands, copy_label="manual update commands", class_name="manual-code")} {_code_window(commands, copy_label="manual update commands", class_name="manual-code")}
</div> </div>
""" """)
)
def _fritzbox_fields( def _fritzbox_fields(
@@ -196,25 +190,23 @@ def _fritzbox_fields(
def _secret_notice(*, include_registration: bool = False) -> SafeHtml: def _secret_notice(*, include_registration: bool = False) -> SafeHtml:
secret_items = "Update URL, register URL, and dashboard link" if include_registration else "Update URL and dashboard link" secret_items = (
return trusted( "Update URL, register URL, and dashboard link" if include_registration else "Update URL and dashboard link"
f""" )
return trusted(f"""
<div class="alert alert-info my-3 py-2 small" role="note"> <div class="alert alert-info my-3 py-2 small" role="note">
<strong>Public:</strong> Hostname and CNAME target can be shared. <strong>Public:</strong> Hostname and CNAME target can be shared.
<strong>Secret:</strong> {escape(secret_items)} can update or manage this hostname. <strong>Secret:</strong> {escape(secret_items)} can update or manage this hostname.
Secrets are hidden on screen. Copy still copies the real value. Secrets are hidden on screen. Copy still copies the real value.
</div> </div>
""" """)
)
def _message_band(message: str | None) -> SafeHtml: def _message_band(message: str | None) -> SafeHtml:
if not message: if not message:
return trusted("") return trusted("")
return trusted( return trusted(f"""
f"""
<div class="alert alert-warning mb-0 rounded-0 border-start-0 border-end-0" role="status"> <div class="alert alert-warning mb-0 rounded-0 border-start-0 border-end-0" role="status">
<div class="container">{escape(message)}</div> <div class="container">{escape(message)}</div>
</div> </div>
""" """)
)
+3 -1
View File
@@ -17,7 +17,9 @@ def parse_form(body: bytes) -> dict[str, list[str]]:
try: try:
return urllib.parse.parse_qs(decoded, keep_blank_values=True, max_num_fields=MAX_FORM_FIELDS) return urllib.parse.parse_qs(decoded, keep_blank_values=True, max_num_fields=MAX_FORM_FIELDS)
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=400, detail=f"form must not contain more than {MAX_FORM_FIELDS} fields") from exc raise HTTPException(
status_code=400, detail=f"form must not contain more than {MAX_FORM_FIELDS} fields"
) from exc
async def request_form(request: Request, settings: DdnsSettings) -> dict[str, list[str]]: async def request_form(request: Request, settings: DdnsSettings) -> dict[str, list[str]]:
+15 -11
View File
@@ -18,8 +18,7 @@ def _top_nav(label: str | None = None, links: SafeHtml | None = None) -> SafeHtm
{_svg_icon("sun", "theme-icon theme-icon-sun")} {_svg_icon("sun", "theme-icon theme-icon-sun")}
</button> </button>
""" """
return trusted( return trusted(f"""
f"""
<a class="skip-link" href="#main">Skip to content</a> <a class="skip-link" href="#main">Skip to content</a>
<nav class="navbar navbar-expand-sm site-nav" aria-label="Main navigation"> <nav class="navbar navbar-expand-sm site-nav" aria-label="Main navigation">
<div class="container"> <div class="container">
@@ -31,13 +30,11 @@ def _top_nav(label: str | None = None, links: SafeHtml | None = None) -> SafeHtm
</div> </div>
</div> </div>
</nav> </nav>
""" """)
)
def _site_footer() -> SafeHtml: def _site_footer() -> SafeHtml:
return trusted( return trusted(f"""
f"""
<footer class="site-footer border-top"> <footer class="site-footer border-top">
<div class="container footer-inner"> <div class="container footer-inner">
<div class="footer-made">Made with <span aria-label="love">❤️</span> by karl</div> <div class="footer-made">Made with <span aria-label="love">❤️</span> by karl</div>
@@ -65,8 +62,7 @@ def _site_footer() -> SafeHtml:
</nav> </nav>
</div> </div>
</footer> </footer>
""" """)
)
def _page( def _page(
@@ -85,7 +81,11 @@ def _page(
raise TypeError("body must be a SafeHtml fragment built by a template helper") raise TypeError("body must be a SafeHtml fragment built by a template helper")
extra_styles_html = extra_styles or trusted("") extra_styles_html = extra_styles or trusted("")
extra_scripts_html = extra_scripts or trusted("") extra_scripts_html = extra_scripts or trusted("")
preload_logo_html = trusted('<link rel="preload" as="image" href="/logo.webp?v=3" type="image/webp">') if preload_logo else trusted("") preload_logo_html = (
trusted('<link rel="preload" as="image" href="/logo.webp?v=3" type="image/webp">')
if preload_logo
else trusted("")
)
canonical_html = trusted(f'<link rel="canonical" href="{attr(canonical_url)}">') if canonical_url else trusted("") canonical_html = trusted(f'<link rel="canonical" href="{attr(canonical_url)}">') if canonical_url else trusted("")
social_url = canonical_url or "" social_url = canonical_url or ""
social_image = image_url or "" social_image = image_url or ""
@@ -93,14 +93,18 @@ def _page(
if social_url: if social_url:
social_card = "summary_large_image" if social_image else "summary" social_card = "summary_large_image" if social_image else "summary"
social_image_alt = image_alt or title social_image_alt = image_alt or title
social_image_html = f""" social_image_html = (
f"""
<meta property="og:image" content="{attr(social_image)}"> <meta property="og:image" content="{attr(social_image)}">
<meta property="og:image:width" content="1200"> <meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630"> <meta property="og:image:height" content="630">
<meta property="og:image:alt" content="{attr(social_image_alt)}"> <meta property="og:image:alt" content="{attr(social_image_alt)}">
<meta name="twitter:image" content="{attr(social_image)}"> <meta name="twitter:image" content="{attr(social_image)}">
<meta name="twitter:image:alt" content="{attr(social_image_alt)}"> <meta name="twitter:image:alt" content="{attr(social_image_alt)}">
""" if social_image else "" """
if social_image
else ""
)
social_html = trusted(f""" social_html = trusted(f"""
<meta property="og:type" content="website"> <meta property="og:type" content="website">
<meta property="og:title" content="{attr(title)}"> <meta property="og:title" content="{attr(title)}">
-1
View File
@@ -47,7 +47,6 @@ class RequestBodyLimitMiddleware:
await response(scope, receive, send) await response(scope, receive, send)
def install_security_middleware(app: FastAPI, settings: DdnsSettings, store: DdnsStore) -> None: def install_security_middleware(app: FastAPI, settings: DdnsSettings, store: DdnsStore) -> None:
@app.middleware("http") @app.middleware("http")
async def security_middleware(request: Request, call_next): async def security_middleware(request: Request, call_next):
+35 -22
View File
@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping, Sequence
from datetime import datetime from datetime import datetime
from urllib.parse import urlencode from urllib.parse import urlencode
@@ -30,13 +31,14 @@ def _admin_secret_panel(title: str, rows: list[SafeHtml]) -> SafeHtml:
</section> </section>
""") """)
def _render_admin_page( def _render_admin_page(
service: DdnsService, service: DdnsService,
settings: DdnsSettings, settings: DdnsSettings,
accounts: list[dict[str, str | int | None]], accounts: Sequence[Mapping[str, object]],
cleanup_runs: list[dict[str, str | int]], cleanup_runs: Sequence[Mapping[str, object]],
scheduled_tasks: list[dict[str, str | int | None]], scheduled_tasks: Sequence[Mapping[str, object]],
update_events: list[dict[str, str | int | None]], update_events: Sequence[Mapping[str, object]],
service_metrics: dict[str, float], service_metrics: dict[str, float],
table_counts: dict[str, int], table_counts: dict[str, int],
table_offsets: dict[str, int], table_offsets: dict[str, int],
@@ -55,8 +57,16 @@ def _render_admin_page(
accounts_count = escape(table_counts.get("accounts", len(accounts))) accounts_count = escape(table_counts.get("accounts", len(accounts)))
event_count = escape(table_counts.get("update_events", len(update_events))) event_count = escape(table_counts.get("update_events", len(update_events)))
metrics_html = _metrics_panel(service_metrics) metrics_html = _metrics_panel(service_metrics)
account_pager = _server_page_links(admin_path, "accounts_offset", table_offsets.get("accounts", 0), table_limit, table_counts.get("accounts", 0)) account_pager = _server_page_links(
event_pager = _server_page_links(admin_path, "update_events_offset", table_offsets.get("update_events", 0), table_limit, table_counts.get("update_events", 0)) admin_path, "accounts_offset", table_offsets.get("accounts", 0), table_limit, table_counts.get("accounts", 0)
)
event_pager = _server_page_links(
admin_path,
"update_events_offset",
table_offsets.get("update_events", 0),
table_limit,
table_counts.get("update_events", 0),
)
cleanup_panel = _cleanup_panel(settings, cleanup_runs, scheduled_tasks, safe_admin_path, csrf) cleanup_panel = _cleanup_panel(settings, cleanup_runs, scheduled_tasks, safe_admin_path, csrf)
totals_html = _totals_strip(table_counts, service_metrics) totals_html = _totals_strip(table_counts, service_metrics)
return _page( return _page(
@@ -142,10 +152,7 @@ def _totals_strip(table_counts: dict[str, int], metrics: dict[str, float]) -> Sa
] ]
return trusted( return trusted(
'<div class="admin-total-strip" aria-label="Service totals">' '<div class="admin-total-strip" aria-label="Service totals">'
+ "".join( + "".join(f"<span><strong>{escape(value)}</strong>{escape(label)}</span>" for label, value in items)
f'<span><strong>{escape(value)}</strong>{escape(label)}</span>'
for label, value in items
)
+ "</div>" + "</div>"
) )
@@ -181,12 +188,15 @@ def _server_page_links(admin_path: str, offset_name: str, offset: int, limit: in
def _cleanup_panel( def _cleanup_panel(
settings: DdnsSettings, settings: DdnsSettings,
cleanup_runs: list[dict[str, str | int]], cleanup_runs: Sequence[Mapping[str, object]],
scheduled_tasks: list[dict[str, str | int | None]], scheduled_tasks: Sequence[Mapping[str, object]],
safe_admin_path: str, safe_admin_path: str,
csrf: str, csrf: str,
) -> SafeHtml: ) -> SafeHtml:
task = next((item for item in scheduled_tasks if item.get("name") == "cleanup"), scheduled_tasks[0] if scheduled_tasks else {}) task = next(
(item for item in scheduled_tasks if item.get("name") == "cleanup"),
scheduled_tasks[0] if scheduled_tasks else {},
)
last_run = task.get("last_finished_at") or (cleanup_runs[0].get("created_at") if cleanup_runs else None) last_run = task.get("last_finished_at") or (cleanup_runs[0].get("created_at") if cleanup_runs else None)
next_run = task.get("next_run_at") next_run = task.get("next_run_at")
status = task.get("last_status") or "pending" status = task.get("last_status") or "pending"
@@ -235,14 +245,14 @@ def _metrics_panel(metrics: dict[str, float]) -> SafeHtml:
("Blocked requests", _metric_int(metrics.get("rate_limit_hits_total", 0))), ("Blocked requests", _metric_int(metrics.get("rate_limit_hits_total", 0))),
("Average update time", f"{average_latency:.1f} ms"), ("Average update time", f"{average_latency:.1f} ms"),
("Last update time", f"{metrics.get('last_update_latency_ms', 0):.1f} ms"), ("Last update time", f"{metrics.get('last_update_latency_ms', 0):.1f} ms"),
("Cleanup ok/errors", f"{_metric_int(metrics.get('cleanup_success_total', 0))}/{_metric_int(metrics.get('cleanup_errors_total', 0))}"), (
"Cleanup ok/errors",
f"{_metric_int(metrics.get('cleanup_success_total', 0))}/{_metric_int(metrics.get('cleanup_errors_total', 0))}",
),
] ]
cards = "\n".join( cards = "\n".join(f"""
f"""
<span><strong>{escape(value)}</strong>{escape(label)}</span> <span><strong>{escape(value)}</strong>{escape(label)}</span>
""" """ for label, value in items)
for label, value in items
)
return trusted(f""" return trusted(f"""
<div class="admin-metric-strip" aria-label="Service metrics"> <div class="admin-metric-strip" aria-label="Service metrics">
{cards} {cards}
@@ -250,13 +260,16 @@ def _metrics_panel(metrics: dict[str, float]) -> SafeHtml:
""") """)
def _metric_int(value: float) -> str: def _metric_int(value: object) -> str:
return str(int(value)) try:
return str(int(float(str(value))))
except ValueError:
return "0"
def _format_interval(seconds: object) -> str: def _format_interval(seconds: object) -> str:
try: try:
value = int(seconds) value = int(str(seconds))
except (TypeError, ValueError): except (TypeError, ValueError):
return str(seconds) return str(seconds)
if value % 3600 == 0: if value % 3600 == 0:
+3 -2
View File
@@ -1,12 +1,13 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping
from datetime import datetime from datetime import datetime
from .components import _event_label from .components import _event_label
from .html_utils import attr, escape from .html_utils import attr, escape
def _account_row(account: dict[str, str | int | None], csrf: str = "", admin_path: str = "/admin") -> str: def _account_row(account: Mapping[str, object], csrf: str = "", admin_path: str = "/admin") -> str:
raw_hostname = str(account["hostname"]) raw_hostname = str(account["hostname"])
hostname = escape(raw_hostname) hostname = escape(raw_hostname)
hostname_attr = attr(raw_hostname) hostname_attr = attr(raw_hostname)
@@ -77,7 +78,7 @@ def _admin_table_pagination() -> str:
""" """
def _event_row(event: dict[str, str | int | None]) -> str: def _event_row(event: Mapping[str, object]) -> str:
created_raw = str(event.get("created_at") or "-") created_raw = str(event.get("created_at") or "-")
hostname_raw = str(event.get("hostname") or "-") hostname_raw = str(event.get("hostname") or "-")
detail_raw = str(event.get("detail") or "-") detail_raw = str(event.get("detail") or "-")
+21 -14
View File
@@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping
from ..services.ddns import DdnsService from ..services.ddns import DdnsService
from ..settings import DdnsSettings from ..settings import DdnsSettings
from .components import ( from .components import (
@@ -19,7 +21,7 @@ def _render_management_page(
service: DdnsService, service: DdnsService,
settings: DdnsSettings, settings: DdnsSettings,
management_slug: str, management_slug: str,
account: dict[str, str | int | None], account: Mapping[str, object],
csrf_token: str, csrf_token: str,
message: str | None = None, message: str | None = None,
request_ip: str | None = None, request_ip: str | None = None,
@@ -37,11 +39,19 @@ def _render_management_page(
fritzbox_fields = _fritzbox_fields(update_url, hostname) fritzbox_fields = _fritzbox_fields(update_url, hostname)
cname_record = f"your-subdomain.example.com. CNAME {hostname}." cname_record = f"your-subdomain.example.com. CNAME {hostname}."
manual_commands = _manual_update_commands(update_url, request_ip) manual_commands = _manual_update_commands(update_url, request_ip)
history_rows = "\n".join(_user_event_row(event) for event in service.store.list_update_events_for_hostname(str(account["hostname"]), 10)) or """ history_rows = (
"\n".join(
_user_event_row(event)
for event in service.store.list_update_events_for_hostname(str(account["hostname"]), 10)
)
or """
<tr><td colspan="6" class="empty">No router updates yet.</td></tr> <tr><td colspan="6" class="empty">No router updates yet.</td></tr>
""" """
)
message_html = _management_message(message) message_html = _management_message(message)
dashboard_password_panel = _dashboard_password_panel(management_slug, csrf_token, bool(account.get("dashboard_password_hash"))) dashboard_password_panel = _dashboard_password_panel(
management_slug, csrf_token, bool(account.get("dashboard_password_hash"))
)
return _page( return _page(
"KarlDNS management", "KarlDNS management",
trusted(f""" trusted(f"""
@@ -143,11 +153,12 @@ def _render_management_page(
extra_styles=trusted('<link href="/admin.css?v=5" rel="stylesheet">'), extra_styles=trusted('<link href="/admin.css?v=5" rel="stylesheet">'),
) )
def _render_pending_management_page( def _render_pending_management_page(
service: DdnsService, service: DdnsService,
settings: DdnsSettings, settings: DdnsSettings,
management_slug: str, management_slug: str,
account: dict[str, str | int | None], account: Mapping[str, object],
csrf_token: str, csrf_token: str,
message: str | None = None, message: str | None = None,
request_ip: str | None = None, request_ip: str | None = None,
@@ -224,13 +235,11 @@ def _render_pending_management_page(
def _management_message(message: str | None) -> SafeHtml: def _management_message(message: str | None) -> SafeHtml:
if not message: if not message:
return trusted("") return trusted("")
return trusted( return trusted(f"""
f"""
<div class="alert alert-info mb-0 rounded-0 border-start-0 border-end-0" role="status"> <div class="alert alert-info mb-0 rounded-0 border-start-0 border-end-0" role="status">
<div class="container">{escape(message)}</div> <div class="container">{escape(message)}</div>
</div> </div>
""" """)
)
def _dashboard_password_panel(management_slug: str, csrf_token: str, password_enabled: bool) -> SafeHtml: def _dashboard_password_panel(management_slug: str, csrf_token: str, password_enabled: bool) -> SafeHtml:
@@ -254,8 +263,7 @@ def _dashboard_password_panel(management_slug: str, csrf_token: str, password_en
<input class="form-control" id="dashboard-current-password" name="current_dashboard_password" type="password" autocomplete="current-password" required> <input class="form-control" id="dashboard-current-password" name="current_dashboard_password" type="password" autocomplete="current-password" required>
</div> </div>
""" """
return trusted( return trusted(f"""
f"""
<section class="dashboard-section"> <section class="dashboard-section">
<div class="container"> <div class="container">
<div class="dashboard-settings-grid"> <div class="dashboard-settings-grid">
@@ -285,13 +293,12 @@ def _dashboard_password_panel(management_slug: str, csrf_token: str, password_en
</div> </div>
</div> </div>
</section> </section>
""" """)
)
def _render_management_unlock_page( def _render_management_unlock_page(
management_slug: str, management_slug: str,
account: dict[str, str | int | None], account: Mapping[str, object],
csrf_token: str, csrf_token: str,
message: str | None = None, message: str | None = None,
) -> str: ) -> str:
@@ -333,7 +340,7 @@ def _render_management_unlock_page(
) )
def _user_event_row(event: dict[str, str | int | None]) -> str: def _user_event_row(event: Mapping[str, object]) -> str:
created = escape(event.get("created_at") or "-") created = escape(event.get("created_at") or "-")
raw_status = str(event.get("status") or "") raw_status = str(event.get("status") or "")
status = escape(_event_label(raw_status)) status = escape(_event_label(raw_status))
+4 -1
View File
@@ -99,6 +99,7 @@ def _render_public_home(
}, },
) )
def _render_privacy_page(settings: DdnsSettings) -> str: def _render_privacy_page(settings: DdnsSettings) -> str:
base_url = settings.public_base_url.rstrip("/") base_url = settings.public_base_url.rstrip("/")
description = "Privacy and legal information for KarlDNS, including stored hostnames, update links, IP history, and cleanup behavior." description = "Privacy and legal information for KarlDNS, including stored hostnames, update links, IP history, and cleanup behavior."
@@ -159,7 +160,9 @@ def _render_api_docs_page(settings: DdnsSettings, request_ip: str | None = None)
base_url = settings.public_base_url.rstrip("/") base_url = settings.public_base_url.rstrip("/")
example_ipv4, _ = _example_update_ips(request_ip) example_ipv4, _ = _example_update_ips(request_ip)
create_example = f"curl -sS -X POST {base_url}/api/v1/hostnames" create_example = f"curl -sS -X POST {base_url}/api/v1/hostnames"
register_example = f"curl -sS -X POST '{base_url}/api/v1/router/register/<karl_dashboard_secret>?myip={example_ipv4}'" register_example = (
f"curl -sS -X POST '{base_url}/api/v1/router/register/<karl_dashboard_secret>?myip={example_ipv4}'"
)
update_example = f"curl -sS '{base_url}/api/v1/router/update/<karl_update_secret>?myip={example_ipv4}'" update_example = f"curl -sS '{base_url}/api/v1/router/update/<karl_update_secret>?myip={example_ipv4}'"
status_example = f"curl -sS '{base_url}/api/v1/hostnames/<karl_dashboard_secret>?history_limit=10'" status_example = f"curl -sS '{base_url}/api/v1/hostnames/<karl_dashboard_secret>?history_limit=10'"
rotate_example = f"curl -sS -X POST {base_url}/api/v1/hostnames/<karl_dashboard_secret>/rotate-update-url" rotate_example = f"curl -sS -X POST {base_url}/api/v1/hostnames/<karl_dashboard_secret>/rotate-update-url"
+2 -4
View File
@@ -8,8 +8,7 @@ def _linux_installer_section(base_url: str) -> SafeHtml:
base_url = base_url.rstrip("/") base_url = base_url.rstrip("/")
system_install = f"curl -fsSL {base_url}/install.sh | sudo sh" system_install = f"curl -fsSL {base_url}/install.sh | sudo sh"
system_uninstall = f"curl -fsSL {base_url}/uninstall.sh | sudo sh" system_uninstall = f"curl -fsSL {base_url}/uninstall.sh | sudo sh"
return trusted( return trusted(f"""
f"""
<section class="section" id="linux-installer"> <section class="section" id="linux-installer">
<div class="container narrow"> <div class="container narrow">
<div class="text-center mb-4"> <div class="text-center mb-4">
@@ -31,5 +30,4 @@ def _linux_installer_section(base_url: str) -> SafeHtml:
</div> </div>
</div> </div>
</section> </section>
""" """)
)
+14
View File
@@ -33,7 +33,9 @@ Issues = "https://github.com/StasonJatham/karldns.de/issues"
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = [
"black>=26.5.1",
"httpx>=0.28", "httpx>=0.28",
"mypy>=2.1.0",
"pytest>=8.2", "pytest>=8.2",
"ruff>=0.8", "ruff>=0.8",
] ]
@@ -55,3 +57,15 @@ target-version = "py311"
[tool.ruff.lint] [tool.ruff.lint]
select = ["E", "F", "I", "UP", "B"] select = ["E", "F", "I", "UP", "B"]
ignore = ["E501"] ignore = ["E501"]
[tool.black]
line-length = 120
target-version = ["py311"]
[tool.mypy]
python_version = "3.11"
files = ["karldns"]
check_untyped_defs = true
warn_unused_ignores = true
warn_redundant_casts = true
show_error_codes = true
+19 -5
View File
@@ -138,8 +138,14 @@ def test_update_events_page_orders_raw_and_rollup_events_in_sql(tmp_path: Path)
store.log_update_rollup("rollup-mid.example.net", "203.0.113.3", None, "unchanged", "ok", "198.51.100.3") store.log_update_rollup("rollup-mid.example.net", "203.0.113.3", None, "unchanged", "ok", "198.51.100.3")
with store.connect() as conn: with store.connect() as conn:
conn.execute("UPDATE update_events SET created_at = ? WHERE hostname = ?", ("2026-01-01T00:00:00+00:00", "raw-old.example.net")) conn.execute(
conn.execute("UPDATE update_events SET created_at = ? WHERE hostname = ?", ("2026-01-03T00:00:00+00:00", "raw-new.example.net")) "UPDATE update_events SET created_at = ? WHERE hostname = ?",
("2026-01-01T00:00:00+00:00", "raw-old.example.net"),
)
conn.execute(
"UPDATE update_events SET created_at = ? WHERE hostname = ?",
("2026-01-03T00:00:00+00:00", "raw-new.example.net"),
)
conn.execute( conn.execute(
"UPDATE update_event_rollups SET last_seen_at = ? WHERE hostname = ?", "UPDATE update_event_rollups SET last_seen_at = ? WHERE hostname = ?",
("2026-01-02T00:00:00+00:00", "rollup-mid.example.net"), ("2026-01-02T00:00:00+00:00", "rollup-mid.example.net"),
@@ -156,8 +162,14 @@ def test_update_events_support_keyset_pagination(tmp_path: Path) -> None:
store.log_update_event("raw-old.example.net", "203.0.113.1", None, "updated", "ok", "198.51.100.1") store.log_update_event("raw-old.example.net", "203.0.113.1", None, "updated", "ok", "198.51.100.1")
store.log_update_event("raw-new.example.net", "203.0.113.2", None, "updated", "ok", "198.51.100.2") store.log_update_event("raw-new.example.net", "203.0.113.2", None, "updated", "ok", "198.51.100.2")
with store.connect() as conn: with store.connect() as conn:
conn.execute("UPDATE update_events SET created_at = ? WHERE hostname = ?", ("2026-01-01T00:00:00+00:00", "raw-old.example.net")) conn.execute(
conn.execute("UPDATE update_events SET created_at = ? WHERE hostname = ?", ("2026-01-02T00:00:00+00:00", "raw-new.example.net")) "UPDATE update_events SET created_at = ? WHERE hostname = ?",
("2026-01-01T00:00:00+00:00", "raw-old.example.net"),
)
conn.execute(
"UPDATE update_events SET created_at = ? WHERE hostname = ?",
("2026-01-02T00:00:00+00:00", "raw-new.example.net"),
)
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin")) app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
client = TestClient(app) client = TestClient(app)
@@ -247,7 +259,9 @@ def test_admin_can_rotate_links_and_password(tmp_path: Path) -> None:
) )
assert update.status_code == 200 assert update.status_code == 200
assert "Update URL changed" in update.text assert "Update URL changed" in update.text
assert client.get(f"/api/v1/router/update/{account['update_slug']}", params={"myip": "203.0.113.9"}).status_code == 404 assert (
client.get(f"/api/v1/router/update/{account['update_slug']}", params={"myip": "203.0.113.9"}).status_code == 404
)
management = client.post( management = client.post(
"/admin/hostnames/rotate", "/admin/hostnames/rotate",
+11 -20
View File
@@ -116,8 +116,7 @@ def test_first_run_creates_schema_and_repeated_startup_is_idempotent(tmp_path: P
def test_startup_migrates_legacy_cleanup_run_columns(tmp_path: Path) -> None: def test_startup_migrates_legacy_cleanup_run_columns(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3" database_path = tmp_path / "ddns.sqlite3"
with sqlite3.connect(database_path) as conn: with sqlite3.connect(database_path) as conn:
conn.execute( conn.execute("""
"""
CREATE TABLE cleanup_runs ( CREATE TABLE cleanup_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
@@ -125,8 +124,7 @@ def test_startup_migrates_legacy_cleanup_run_columns(tmp_path: Path) -> None:
abandoned_setups_deleted INTEGER NOT NULL, abandoned_setups_deleted INTEGER NOT NULL,
unused_accounts_deleted INTEGER NOT NULL unused_accounts_deleted INTEGER NOT NULL
) )
""" """)
)
conn.execute( conn.execute(
""" """
INSERT INTO cleanup_runs( INSERT INTO cleanup_runs(
@@ -144,12 +142,10 @@ def test_startup_migrates_legacy_cleanup_run_columns(tmp_path: Path) -> None:
with store.connect() as conn: with store.connect() as conn:
columns = {row[1] for row in conn.execute("PRAGMA table_info(cleanup_runs)")} columns = {row[1] for row in conn.execute("PRAGMA table_info(cleanup_runs)")}
row = conn.execute( row = conn.execute("""
"""
SELECT unused_accounts_deleted SELECT unused_accounts_deleted
FROM cleanup_runs FROM cleanup_runs
""" """).fetchone()
).fetchone()
assert columns == {"id", "created_at", "unused_accounts_deleted"} assert columns == {"id", "created_at", "unused_accounts_deleted"}
assert tuple(row) == (1,) assert tuple(row) == (1,)
@@ -225,16 +221,11 @@ def test_sqlite_startup_tuning_is_idempotent(tmp_path: Path) -> None:
temp_store = conn.execute("PRAGMA temp_store").fetchone()[0] temp_store = conn.execute("PRAGMA temp_store").fetchone()[0]
mmap_size = conn.execute("PRAGMA mmap_size").fetchone()[0] mmap_size = conn.execute("PRAGMA mmap_size").fetchone()[0]
user_version = conn.execute("PRAGMA user_version").fetchone()[0] user_version = conn.execute("PRAGMA user_version").fetchone()[0]
index_names = { index_names = {row[1] for row in conn.execute("""
row[1]
for row in conn.execute(
"""
SELECT type, name SELECT type, name
FROM sqlite_master FROM sqlite_master
WHERE type = 'index' WHERE type = 'index'
""" """)}
)
}
assert busy_timeout == 10_000 assert busy_timeout == 10_000
assert journal_mode == "wal" assert journal_mode == "wal"
@@ -255,8 +246,7 @@ def test_accounts_schema_migration_removes_legacy_router_credentials(tmp_path: P
database_path = tmp_path / "ddns.sqlite3" database_path = tmp_path / "ddns.sqlite3"
with sqlite3.connect(database_path) as conn: with sqlite3.connect(database_path) as conn:
conn.execute("PRAGMA user_version = 2") conn.execute("PRAGMA user_version = 2")
conn.execute( conn.execute("""
"""
CREATE TABLE accounts ( CREATE TABLE accounts (
hostname TEXT PRIMARY KEY, hostname TEXT PRIMARY KEY,
username TEXT NOT NULL, username TEXT NOT NULL,
@@ -268,8 +258,7 @@ def test_accounts_schema_migration_removes_legacy_router_credentials(tmp_path: P
accepted_at TEXT, accepted_at TEXT,
disabled INTEGER NOT NULL DEFAULT 0 disabled INTEGER NOT NULL DEFAULT 0
) )
""" """)
)
conn.execute( conn.execute(
""" """
INSERT INTO accounts( INSERT INTO accounts(
@@ -303,7 +292,9 @@ def test_accounts_schema_migration_removes_legacy_router_credentials(tmp_path: P
assert account["hostname"] == "home.ddns.example.net" assert account["hostname"] == "home.ddns.example.net"
def test_rate_limit_flush_requeues_counts_after_database_failure(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: def test_rate_limit_flush_requeues_counts_after_database_failure(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
store = DdnsStore(tmp_path / "ddns.sqlite3") store = DdnsStore(tmp_path / "ddns.sqlite3")
assert store.allow_rate_limit("198.51.100.1:/api", 60) is True assert store.allow_rate_limit("198.51.100.1:/api", 60) is True
+4 -1
View File
@@ -140,7 +140,10 @@ esac
assert "KarlDNS: registering hostname" in result.stdout assert "KarlDNS: registering hostname" in result.stdout
config = home_dir / ".config" / "karldns" / "updater.env" config = home_dir / ".config" / "karldns" / "updater.env"
config_text = config.read_text(encoding="utf-8") config_text = config.read_text(encoding="utf-8")
assert "KARLDNS_UPDATE_URL='https://karldns.test/api/v1/router/update/karl_update_secret_12345678901234567890'" in config_text assert (
"KARLDNS_UPDATE_URL='https://karldns.test/api/v1/router/update/karl_update_secret_12345678901234567890'"
in config_text
)
assert "KARLDNS_HOSTNAME='abc.karldns.test'" in config_text assert "KARLDNS_HOSTNAME='abc.karldns.test'" in config_text
+1 -1
View File
@@ -136,7 +136,7 @@ def test_dashboard_unlock_cookie_rejects_password_hash_signed_forgery(tmp_path:
def test_settings_reject_unknown_constructor_fields() -> None: def test_settings_reject_unknown_constructor_fields() -> None:
with pytest.raises(ValueError, match="Extra inputs are not permitted"): with pytest.raises(ValueError, match="Extra inputs are not permitted"):
DdnsSettings(admn_password="typo") DdnsSettings(**{"admn_password": "typo"})
def test_trusted_host_middleware_rejects_unlisted_host(tmp_path: Path) -> None: def test_trusted_host_middleware_rejects_unlisted_host(tmp_path: Path) -> None:
+40 -13
View File
@@ -58,7 +58,9 @@ def test_legacy_custom_account_form_is_rejected(tmp_path: Path) -> None:
) )
client = TestClient(app) client = TestClient(app)
response = client.post("/accounts", data={"mode": "custom", "hostname": "home.example.net", "claim_secret": "legacy"}) response = client.post(
"/accounts", data={"mode": "custom", "hostname": "home.example.net", "claim_secret": "legacy"}
)
assert response.status_code == 404 assert response.status_code == 404
assert app.state.store.count_accounts() == 0 assert app.state.store.count_accounts() == 0
@@ -174,14 +176,18 @@ def test_generated_hostname_must_be_activated_before_updates_work(tmp_path: Path
account = app.state.store.get_account_by_management_slug(management_slug) account = app.state.store.get_account_by_management_slug(management_slug)
assert account["accepted_at"] is None assert account["accepted_at"] is None
assert client.get(f"/api/v1/router/update/{account['update_slug']}", params={"myip": "203.0.113.9"}).status_code == 404 assert (
client.get(f"/api/v1/router/update/{account['update_slug']}", params={"myip": "203.0.113.9"}).status_code == 404
)
activated = client.post(f"/m/{management_slug}/activate", data={"csrf": _csrf(created.text)}) activated = client.post(f"/m/{management_slug}/activate", data={"csrf": _csrf(created.text)})
assert activated.status_code == 200 assert activated.status_code == 200
assert "Hostname registered" in activated.text assert "Hostname registered" in activated.text
assert app.state.store.get_account_by_management_slug(management_slug)["accepted_at"] assert app.state.store.get_account_by_management_slug(management_slug)["accepted_at"]
assert client.get(f"/api/v1/router/update/{account['update_slug']}", params={"myip": "203.0.113.9"}).status_code == 200 assert (
client.get(f"/api/v1/router/update/{account['update_slug']}", params={"myip": "203.0.113.9"}).status_code == 200
)
def test_old_magic_route_is_gone(tmp_path: Path) -> None: def test_old_magic_route_is_gone(tmp_path: Path) -> None:
@@ -322,7 +328,10 @@ def test_management_dashboard_password_can_be_removed(tmp_path: Path) -> None:
unlock = client.post( unlock = client.post(
f"/m/{account['management_slug']}/unlock", f"/m/{account['management_slug']}/unlock",
data={"csrf": _csrf(client.get(f"/m/{account['management_slug']}").text), "dashboard_password": "correct horse"}, data={
"csrf": _csrf(client.get(f"/m/{account['management_slug']}").text),
"dashboard_password": "correct horse",
},
) )
assert unlock.status_code == 200 assert unlock.status_code == 200
@@ -430,12 +439,18 @@ def test_management_dashboard_rotates_update_url(tmp_path: Path) -> None:
assert "data-secret-toggle" in dashboard.text assert "data-secret-toggle" in dashboard.text
assert 'type="password"' in dashboard.text assert 'type="password"' in dashboard.text
rotated_update = client.post(f"/m/{account['management_slug']}/rotate", data={"csrf": _csrf(dashboard.text), "action": "update"}) rotated_update = client.post(
f"/m/{account['management_slug']}/rotate", data={"csrf": _csrf(dashboard.text), "action": "update"}
)
assert rotated_update.status_code == 200 assert rotated_update.status_code == 200
assert "Update URL changed. Update your router now." in rotated_update.text assert "Update URL changed. Update your router now." in rotated_update.text
assert "data-secret-input" in rotated_update.text assert "data-secret-input" in rotated_update.text
assert client.get(f"/api/v1/router/update/{account['update_slug']}", params={"myip": "203.0.113.9"}).status_code == 404 assert (
statuses = {event["status"] for event in app.state.store.list_update_events_for_hostname("home.ddns.example.net", 10)} client.get(f"/api/v1/router/update/{account['update_slug']}", params={"myip": "203.0.113.9"}).status_code == 404
)
statuses = {
event["status"] for event in app.state.store.list_update_events_for_hostname("home.ddns.example.net", 10)
}
assert "dashboard_update_url_rotated" in statuses assert "dashboard_update_url_rotated" in statuses
assert "dashboard_router_credentials_rotated" not in statuses assert "dashboard_router_credentials_rotated" not in statuses
@@ -449,13 +464,19 @@ def test_management_dashboard_actions_are_scoped_to_slug_owner(tmp_path: Path) -
client = TestClient(app) client = TestClient(app)
dashboard = client.get(f"/m/{first['management_slug']}") dashboard = client.get(f"/m/{first['management_slug']}")
rotated = client.post(f"/m/{first['management_slug']}/rotate", data={"csrf": _csrf(dashboard.text), "action": "update"}) rotated = client.post(
f"/m/{first['management_slug']}/rotate", data={"csrf": _csrf(dashboard.text), "action": "update"}
)
assert rotated.status_code == 200 assert rotated.status_code == 200
assert store.get_account_by_hostname("first.ddns.example.net")["update_slug"] != first["update_slug"] assert store.get_account_by_hostname("first.ddns.example.net")["update_slug"] != first["update_slug"]
assert store.get_account_by_hostname("second.ddns.example.net")["update_slug"] == second["update_slug"] assert store.get_account_by_hostname("second.ddns.example.net")["update_slug"] == second["update_slug"]
assert client.get(f"/api/v1/router/update/{first['update_slug']}", params={"myip": "203.0.113.1"}).status_code == 404 assert (
assert client.get(f"/api/v1/router/update/{second['update_slug']}", params={"myip": "203.0.113.2"}).status_code == 200 client.get(f"/api/v1/router/update/{first['update_slug']}", params={"myip": "203.0.113.1"}).status_code == 404
)
assert (
client.get(f"/api/v1/router/update/{second['update_slug']}", params={"myip": "203.0.113.2"}).status_code == 200
)
def test_locked_dashboard_rejects_mutating_actions_without_unlock_cookie(tmp_path: Path) -> None: def test_locked_dashboard_rejects_mutating_actions_without_unlock_cookie(tmp_path: Path) -> None:
@@ -558,7 +579,10 @@ def test_public_examples_use_karldns_domain_and_no_shell_prompt(tmp_path: Path)
assert "https://karldns.de/api/v1/router/update/&lt;secret&gt;" in response.text assert "https://karldns.de/api/v1/router/update/&lt;secret&gt;" in response.text
assert "http://127.0.0.1:8099/api/v1/router/update/&lt;secret&gt;" not in response.text assert "http://127.0.0.1:8099/api/v1/router/update/&lt;secret&gt;" not in response.text
assert "$ curl" not in response.text assert "$ curl" not in response.text
assert 'data-copy="curl -sS &#x27;https://karldns.de/api/v1/router/update/&lt;secret&gt;?myip=203.0.113.10&#x27;"' in response.text assert (
'data-copy="curl -sS &#x27;https://karldns.de/api/v1/router/update/&lt;secret&gt;?myip=203.0.113.10&#x27;"'
in response.text
)
def test_application_pages_use_stricter_script_csp(tmp_path: Path) -> None: def test_application_pages_use_stricter_script_csp(tmp_path: Path) -> None:
@@ -579,7 +603,10 @@ def test_application_pages_use_stricter_script_csp(tmp_path: Path) -> None:
assert "<style>" not in response.text assert "<style>" not in response.text
assert "script-src 'self' 'unsafe-inline'" in response.headers["content-security-policy"] assert "script-src 'self' 'unsafe-inline'" in response.headers["content-security-policy"]
assert "style-src 'self'" in response.headers["content-security-policy"] assert "style-src 'self'" in response.headers["content-security-policy"]
assert "connect-src 'self' https://api4.ipify.org https://api6.ipify.org" in response.headers["content-security-policy"] assert (
"connect-src 'self' https://api4.ipify.org https://api6.ipify.org"
in response.headers["content-security-policy"]
)
assert "style-src 'self' 'unsafe-inline'" not in response.headers["content-security-policy"] assert "style-src 'self' 'unsafe-inline'" not in response.headers["content-security-policy"]
bootstrap_css = client.get("/bootstrap-subset.css") bootstrap_css = client.get("/bootstrap-subset.css")
assert bootstrap_css.status_code == 200 assert bootstrap_css.status_code == 200
@@ -622,7 +649,7 @@ def test_footer_links_and_privacy_page_are_available(tmp_path: Path) -> None:
assert 'property="og:image:width" content="1200"' in response.text assert 'property="og:image:width" content="1200"' in response.text
assert 'property="og:image:height" content="630"' in response.text assert 'property="og:image:height" content="630"' in response.text
assert 'name="twitter:card" content="summary_large_image"' in response.text assert 'name="twitter:card" content="summary_large_image"' in response.text
assert 'application/ld+json' in response.text assert "application/ld+json" in response.text
assert "NetworkingApplication" in response.text assert "NetworkingApplication" in response.text
assert "Made with" in response.text assert "Made with" in response.text
assert "❤️" in response.text assert "❤️" in response.text
+6 -2
View File
@@ -16,7 +16,9 @@ def test_router_update_get_accepts_fritzbox_placeholders(tmp_path: Path) -> None
database_path = tmp_path / "ddns.sqlite3" database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path) store = DdnsStore(database_path)
account = store.create_account("home.ddns.example.net", "router") account = store.create_account("home.ddns.example.net", "router")
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin", hostname_suffix="ddns.example.net")) app = make_app(
DdnsSettings(database_path=database_path, admin_password="admin", hostname_suffix="ddns.example.net")
)
client = TestClient(app) client = TestClient(app)
response = client.get( response = client.get(
@@ -182,7 +184,9 @@ def test_slug_update_rejects_hostname_outside_provider_suffix(tmp_path: Path) ->
database_path = tmp_path / "ddns.sqlite3" database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path) store = DdnsStore(database_path)
account = store.create_account("home.example.net", "router") account = store.create_account("home.example.net", "router")
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin", hostname_suffix="ddns.example.net")) app = make_app(
DdnsSettings(database_path=database_path, admin_password="admin", hostname_suffix="ddns.example.net")
)
client = TestClient(app) client = TestClient(app)
response = client.get(_update_path(account), params={"myip": "203.0.113.9"}) response = client.get(_update_path(account), params={"myip": "203.0.113.9"})
Generated
+270
View File
@@ -1,6 +1,10 @@
version = 1 version = 1
revision = 3 revision = 3
requires-python = ">=3.11" requires-python = ">=3.11"
resolution-markers = [
"python_full_version >= '3.15'",
"python_full_version < '3.15'",
]
[[package]] [[package]]
name = "annotated-doc" name = "annotated-doc"
@@ -33,6 +37,83 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
] ]
[[package]]
name = "ast-serialize"
version = "0.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/81/9d/09e27731bd5864a9ce04e3244074e674bb8936bf62b45e0357248717adac/ast_serialize-0.5.0.tar.gz", hash = "sha256:5880091bfe6f4f986f22866375c2e884843e7a0b6343ae41aeea659613d879b6", size = 61157, upload-time = "2026-05-17T17:48:29.429Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c0/9a/13dde51ba9e15f8b97957ab7cb0120d0e381524d651c6bd630b9c359227f/ast_serialize-0.5.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8f5c14f169eb0972c0c21bada5358b23d6047c76583b005234f865b11f1fa00a", size = 1183520, upload-time = "2026-05-17T17:47:30.831Z" },
{ url = "https://files.pythonhosted.org/packages/37/de/5a7f0a9fe68944f536632a5af84676739c7d2582be42deb082634bf3a754/ast_serialize-0.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7d1a2de9de5be04652f0ed60738356ef94f66db37924a9499fffe98dc491aa0b", size = 1175779, upload-time = "2026-05-17T17:47:32.551Z" },
{ url = "https://files.pythonhosted.org/packages/9c/81/0bb853e76e4f6e9a1855d569003c59e19ffac45f7079d91505d1bb212f92/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be5173fb66f9b49026d9d5a2ff0fc7c7009077107c0eb285b2d60fdf1fe10bd1", size = 1233750, upload-time = "2026-05-17T17:47:34.731Z" },
{ url = "https://files.pythonhosted.org/packages/e5/d3/4cf705beeccc08754d0bbda99aefff26110e209b9a07ac8a6b60eec48531/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f8015cd071ac1339924ee2b8098c93e00e155f30a16f40ec9816fcf84f4753f6", size = 1235942, upload-time = "2026-05-17T17:47:36.287Z" },
{ url = "https://files.pythonhosted.org/packages/26/c8/ee097e437ea27dd2b8b227865c875492b585650a5802a22d82b304c8201b/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5499e8797edff2a9186aa313ed382c6b422e798e9332d9953badcee6e69a88f2", size = 1442517, upload-time = "2026-05-17T17:47:38.17Z" },
{ url = "https://files.pythonhosted.org/packages/ff/bd/68063442838f1ba68ec72b5436430bc75b3bb17a1a3c3063f09b0c05ae2b/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6848f2a093fb5548751a9a09bff8fcd229e2bbeb0e3331f391b6ae6d26cd9903", size = 1254081, upload-time = "2026-05-17T17:47:39.826Z" },
{ url = "https://files.pythonhosted.org/packages/50/e2/1e520793bc6a4e4524a6ab022391e827825eaa0c3811828bfdc6852eca26/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:832d4c998e0b091fd60a6d6bceee535483c4d490de9ba85003af835225719261", size = 1259910, upload-time = "2026-05-17T17:47:41.369Z" },
{ url = "https://files.pythonhosted.org/packages/4e/e1/49b60f467979979cfe6913b43948ff25bca971ad0591d181812f163a988e/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:16db7c62ec0b8efe1d7afd283a388d8f74f2605d56032e5a37747d2de8dba027", size = 1250678, upload-time = "2026-05-17T17:47:43.702Z" },
{ url = "https://files.pythonhosted.org/packages/74/ba/66ab9555de6275677566f6574e5ef6c29cb185ea866f643bc06f8280a8ee/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:baf5eb061eb5bccade4128ad42da33787d72f6013809cd1b590376ece8b3c937", size = 1301603, upload-time = "2026-05-17T17:47:46.256Z" },
{ url = "https://files.pythonhosted.org/packages/66/42/6aca9b9abc710014b2be9059689e5dd1679339e78f567ffb4d255a9e2050/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:104e4a35bd7c124173c41760ef9aaea17ddb3f86c65cb643671d59afbe3ee94c", size = 1410332, upload-time = "2026-05-17T17:47:47.899Z" },
{ url = "https://files.pythonhosted.org/packages/47/68/2f76594432a22581ecf878b5e75a9b8601c24b2241cf0bbeb1e21fcf370c/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:36be371028fc1675acb38a331bde160dbab7ff907fdf00b67eb6911aa106951b", size = 1509979, upload-time = "2026-05-17T17:47:50.942Z" },
{ url = "https://files.pythonhosted.org/packages/40/ac/a93c9b58292653f6c595752f677a08e608f903b710594909e9231a389b3b/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:061ee58bdb52341c8201a6df41182a977736bae3b7ded87ca7176ca25a8a47ab", size = 1505002, upload-time = "2026-05-17T17:47:54.093Z" },
{ url = "https://files.pythonhosted.org/packages/14/2e/b278f68c497ee2f1d1576cbbef8db5281cd4a5f2db040537592ac9c8862e/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b15219e9cdc9f53f6f4cb51c009203507228226148c05c5e8fe451c28b435eb3", size = 1456231, upload-time = "2026-05-17T17:47:56.311Z" },
{ url = "https://files.pythonhosted.org/packages/0b/43/419be1c566a4c504cd8fd60ce2f84e790f295495c0f327cfaeadf3d51012/ast_serialize-0.5.0-cp314-cp314t-win32.whl", hash = "sha256:842d1c004bb466c7df036f95fabef789570541922b10976b12f5592a69cf0b38", size = 1058668, upload-time = "2026-05-17T17:47:58.305Z" },
{ url = "https://files.pythonhosted.org/packages/03/6f/c9d4d549295ed05111aeb8853232d1afd9d0a179fddb01eeffbb3a4a6842/ast_serialize-0.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b0c06d760909b095cc466356dfccd05a1c7233a6ca191c020dca2c6a6f16c24c", size = 1101075, upload-time = "2026-05-17T17:48:00.35Z" },
{ url = "https://files.pythonhosted.org/packages/d0/8e/d00c5ab30c58222e07d62956fca86c59d91b9ad32997e633c38b526623a3/ast_serialize-0.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:787baedb0262cc49e8ce37cc15c00ae818e46a165a3b36f5e21ed174998104cb", size = 1075347, upload-time = "2026-05-17T17:48:01.753Z" },
{ url = "https://files.pythonhosted.org/packages/e0/9e/dc2530acb3a60dc6e46d65abf27d1d9f86721694757906a148d90a6860de/ast_serialize-0.5.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:0668aa9459cfa8c9c49ddd2163ebcf43088ba045ef7492af6fe22e0098303101", size = 1191380, upload-time = "2026-05-17T17:48:03.738Z" },
{ url = "https://files.pythonhosted.org/packages/26/0a/bd3d18a582f273d6c843d16bb9e22e9e16365ff7991e92f18f798e9f1224/ast_serialize-0.5.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:bf683d6363edf2b39eed6b6d4fe22d34b6203867a67e27134d9e2a2680c4bc4a", size = 1183879, upload-time = "2026-05-17T17:48:05.463Z" },
{ url = "https://files.pythonhosted.org/packages/40/ae/1f919100f8620887af58fcc381c61a1f218cdf89c6e155f87b213e61010a/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cc22cf0c9be65e71cf88fda130af60d61eb4a79370ad4cfe7900d48a4aa2211", size = 1244529, upload-time = "2026-05-17T17:48:07.008Z" },
{ url = "https://files.pythonhosted.org/packages/c6/ca/6376559dcce707cdbc1d0d9a13c8d3baaaa501e949ce0ebdc4230cd881aa/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f66173891548c9f2726bf27957b41cabce12fa679dc6da505ddbde4d4b3b31cf", size = 1240560, upload-time = "2026-05-17T17:48:08.46Z" },
{ url = "https://files.pythonhosted.org/packages/35/b2/a620e206b5aeb7efbf2710336df57d457cffbb3991076bbcc1147ef9abd4/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e42d729ef2be96a14efbad355093284739e3670ece3e534f82cc8832790911d9", size = 1451172, upload-time = "2026-05-17T17:48:09.922Z" },
{ url = "https://files.pythonhosted.org/packages/fa/e0/4ad5c04c24a40481b2935ce9a0ccdb6023dc8b667167d06ae530cc3512f2/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b725026bafa801dbd7310eb13a75f0a2e370e7e51b2cb225f9d21fcfadf919ee", size = 1265072, upload-time = "2026-05-17T17:48:11.469Z" },
{ url = "https://files.pythonhosted.org/packages/b2/71/4d1d479aa56d0101c40e17720c3d6ac2af7269ea0487a80b18e7bfd1a5b7/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b54f60c1d78767a53b67eaa663f0dfac3afe606aa07f1301572f588b73d64809", size = 1270488, upload-time = "2026-05-17T17:48:13.575Z" },
{ url = "https://files.pythonhosted.org/packages/6d/4f/0de1bbe06f6edef9fde4ed12ca8e7b3ec7e6e2bd4e672c5af487f7957665/ast_serialize-0.5.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:27d51654fc240a1e87e742d353d98eb45b75f62f129086b3596ab53df2ac2a43", size = 1260702, upload-time = "2026-05-17T17:48:15.141Z" },
{ url = "https://files.pythonhosted.org/packages/75/61/e00872439cfdddcc3c1b6cdaa6e5d904ba8e26a18807c67c4e14409d0ca8/ast_serialize-0.5.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c36237c46dd1674542f2109740ea5ea485a169bf1431939ada0434e17934", size = 1311182, upload-time = "2026-05-17T17:48:16.779Z" },
{ url = "https://files.pythonhosted.org/packages/76/8e/699a5b955f7926956c95e9e1d74132acad73c2fe7a426f94da89123c20aa/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1943db345233cc7194a470f13afa9c59772c0b123dea0c9414c4d4ca54369759", size = 1421410, upload-time = "2026-05-17T17:48:18.527Z" },
{ url = "https://files.pythonhosted.org/packages/a9/ae/d5b7626874478997adc7a29ab28accf21e596fb590c944290401dfd0b29e/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:df1c00022cbbcb064bfaa505aa9c9295362443ce5dacb459d1331d3da353f887", size = 1516587, upload-time = "2026-05-17T17:48:20.133Z" },
{ url = "https://files.pythonhosted.org/packages/0c/ce/b59e02a82d9c4244d64cde502e0b00e83e38816abe19155ceb5437402c7f/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:cae65289fc456fde04af979a2be09302ef5d8ab92ef23e596d6746dc267ada27", size = 1515171, upload-time = "2026-05-17T17:48:21.921Z" },
{ url = "https://files.pythonhosted.org/packages/8b/38/d8d90042747d05aa08d4efcf1c99035a5f670a6bf4c214d31644392afbca/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:239a4c354e8d676e9d94631d1d4a64edc6b266f86ff3a5a80aedd344f342c01d", size = 1464668, upload-time = "2026-05-17T17:48:23.544Z" },
{ url = "https://files.pythonhosted.org/packages/dd/51/5b840c4df7334104cecffa28f23904fe81ca89ca223d2450e288de39fd3c/ast_serialize-0.5.0-cp39-abi3-win32.whl", hash = "sha256:143a4ef63285a075871908fda3672dc21864b83a8ec3ee12304aa3e4c5387b9a", size = 1068311, upload-time = "2026-05-17T17:48:25.027Z" },
{ url = "https://files.pythonhosted.org/packages/41/11/ca5672c7d491825bc4cd6702dea106a6b60d928707712ec257c7833ae476/ast_serialize-0.5.0-cp39-abi3-win_amd64.whl", hash = "sha256:cf25572c526add400f26a4750dc6ce0c3bb93fc1f75e7ae0cad4ce4f2cd5c590", size = 1108931, upload-time = "2026-05-17T17:48:26.591Z" },
{ url = "https://files.pythonhosted.org/packages/45/19/cc8bd127d28a43da249aa955cfd164cf8fd534e79e42cea96c4854d72fd0/ast_serialize-0.5.0-cp39-abi3-win_arm64.whl", hash = "sha256:92a31c9c20d25a076edaeec76b128a3535d74a24f340b9a8a7e96c9b86dc9642", size = 1081181, upload-time = "2026-05-17T17:48:28.122Z" },
]
[[package]]
name = "black"
version = "26.5.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "mypy-extensions" },
{ name = "packaging" },
{ name = "pathspec" },
{ name = "platformdirs" },
{ name = "pytokens" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c0/37/5628dd55bf2b34257fc7603f0fe97c40e3aaf24265f416a9c85c95ca1436/black-26.5.1.tar.gz", hash = "sha256:dd321f668053961824bcc1be1cc1df748b2d7e4fa28086b08331e577b0100a73", size = 679439, upload-time = "2026-05-18T16:53:36.107Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4b/96/3c3e09f09f44a37aac36b178a279cd19aa7001bd796187a7b162a294c81f/black-26.5.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:96ae2c733b2aabdd9986e2c5df628ff3473676cd1c5faded1ff496cf6d74083c", size = 1970639, upload-time = "2026-05-18T17:05:11.461Z" },
{ url = "https://files.pythonhosted.org/packages/83/ea/5ad117b9ee3ecd933c712bcbae610006e5b7cc9f41c526cd7ed3b6c4124c/black-26.5.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0e48b87e03bf109288e55cfceadcfa15ff5470aca2851a851950ed2926f450d7", size = 1792130, upload-time = "2026-05-18T17:05:12.983Z" },
{ url = "https://files.pythonhosted.org/packages/06/3a/7c448bc623fcdfa96672531beb5a616ea5e64f6975955254d7731ffb0ad9/black-26.5.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5119fa92ae61f786e8c3662fd60aece1d0a2dd5cca5d0c79417a95e7a4272a59", size = 1846134, upload-time = "2026-05-18T17:05:14.506Z" },
{ url = "https://files.pythonhosted.org/packages/a1/5b/0b39b3a5917f0657ac014ad2edb58c139553a478adfe7f817abf1622ff6e/black-26.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:30d3c14661f2792e9142cce3eeeb1cbc175b3eb5f733be0c8eeb99651e52b0c3", size = 1478883, upload-time = "2026-05-18T17:05:16.542Z" },
{ url = "https://files.pythonhosted.org/packages/4c/48/dc222692e0f95030db1bbfb6c857e76858bad09058221ea7aae815255327/black-26.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:1ef92b76f7733f282fd096ea406200b5a286c42947412b0eaff3a74e3616cefe", size = 1277776, upload-time = "2026-05-18T17:05:18.029Z" },
{ url = "https://files.pythonhosted.org/packages/24/99/7744b906703228264ef73bdd534df88ec1ef3de45c4e78f6d31b9e32d0c9/black-26.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4ad6fa01f941920f54f2bbb35f3df7673428a0ef98a0b0840c2eaef3b110efa8", size = 2012518, upload-time = "2026-05-18T17:05:20.108Z" },
{ url = "https://files.pythonhosted.org/packages/b7/c0/c5a3b1636dfd09c42534f2b3cf33506814f6d3e066fb0879ffa16c1ae860/black-26.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3915f256e75a2d7cf88d8953d37f780455dc586cc72dee059c528fe77f581217", size = 1816016, upload-time = "2026-05-18T17:05:21.84Z" },
{ url = "https://files.pythonhosted.org/packages/1f/0e/36044316b65ca471d3bb6d3703fd06fb50c6b727c3562f6a5a3153634f88/black-26.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d98d4137277c75dfb898ec8d846c4fd68ba1e9cf77f95e2865c203dc18f4c3d", size = 1884150, upload-time = "2026-05-18T17:05:23.546Z" },
{ url = "https://files.pythonhosted.org/packages/b3/33/dafc5808c2af43672912111d7c3354af1615f7e2be3bed7a878461abbe4d/black-26.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:a1dca32d9f1784af512a13410ec204c6f7f0aa9797a111c42e1c03449821c264", size = 1486825, upload-time = "2026-05-18T17:05:25.004Z" },
{ url = "https://files.pythonhosted.org/packages/82/14/b965ee6ad2a311f28bdbf692def3ee9848d2ae289dab28b27657fcee3e78/black-26.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:1037d5ac7b7b310b2632ad867ec8d0e4c4819dcdb0b820f63135da746a24e418", size = 1288646, upload-time = "2026-05-18T17:05:26.477Z" },
{ url = "https://files.pythonhosted.org/packages/3f/5c/c384363980e11e25ca6b93205949bb331fbf35f4e0dbec376dfa6326cec8/black-26.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2b36cf2ddf5566e205f6535f782a62194a184d33e175b64ae8c40b1737522be3", size = 2009020, upload-time = "2026-05-18T17:05:28.132Z" },
{ url = "https://files.pythonhosted.org/packages/0b/df/9f31c5e0babbfed77d505fc5d120beb98b21b33feaeded3924ea941fe360/black-26.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1f7ea64ebfa01b50f693508fc39f875e264446d3b097088f84f203b9d09618a0", size = 1813335, upload-time = "2026-05-18T17:05:31.266Z" },
{ url = "https://files.pythonhosted.org/packages/fb/24/8e7b9a2fa61b0afd82209efe937557d180a1fa055bd7f6161eb9defc3719/black-26.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecb3e624844c798144e9bd986954e0adc81d8911a1f30f375e1252fe26e8c294", size = 1881614, upload-time = "2026-05-18T17:05:32.718Z" },
{ url = "https://files.pythonhosted.org/packages/49/ad/b4e0d9365ba8ac34f6bbab62a4b1b2dd5d618fac3fa1b8db968c844201b5/black-26.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:e1a26503279b6b310669fb0b219c39e4820b77e8189fe80f522bb511f247db0a", size = 1488925, upload-time = "2026-05-18T17:05:34.259Z" },
{ url = "https://files.pythonhosted.org/packages/a1/4b/652b859bf5df88a751c30451b09338f7fd26a77d1271c666992f836b7711/black-26.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c34b25da232ead53a6f335b76dbea124f4d152ad568b9080d6f944bc2b34b52", size = 1289883, upload-time = "2026-05-18T17:05:36.019Z" },
{ url = "https://files.pythonhosted.org/packages/a6/16/a8da8eb208c51c7f4ce74609a45d0dcc6d8a2141e45e81ee5289d1bb0d59/black-26.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e88976690a64b0af98312ca958415849cb42423423c5f2ee74af4b49a97a2168", size = 2004800, upload-time = "2026-05-18T17:05:38.182Z" },
{ url = "https://files.pythonhosted.org/packages/11/8a/a479296a19e383b70a725882a6cf3d786540601ff03cabbaaf1cce864c5a/black-26.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32d5ea7f6c8bdfa6e648326ebca1f02b0764e2a029edc6f8dce2627e19d468c3", size = 1815576, upload-time = "2026-05-18T17:05:40.309Z" },
{ url = "https://files.pythonhosted.org/packages/81/6b/cfaf3d39f25132c156a068f6b805576c9103a84086019507c70e1911ee7d/black-26.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ea8d16dc41655aa113cd64665e7219446cd7e4ff2248d7178eaa905190c86b18", size = 1877927, upload-time = "2026-05-18T17:05:42.463Z" },
{ url = "https://files.pythonhosted.org/packages/66/76/302e313964bcff7e28df329d39f84f5270095730d85ff0acc260610a0d82/black-26.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:577f21094ea469ef92ec1adaf2c9441a226d2144d01a5be2fa823cecf6543e50", size = 1511860, upload-time = "2026-05-18T17:05:43.943Z" },
{ url = "https://files.pythonhosted.org/packages/27/4e/a3827e35e0e567f9f9ee59e2a0ab979267dca98718f25547ca8c6733afd4/black-26.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:ed1a20af114c301a0269bf01163d51dbef72737fd65f850001e7cbe7f3c7abae", size = 1316632, upload-time = "2026-05-18T17:05:45.521Z" },
{ url = "https://files.pythonhosted.org/packages/94/51/f975cae76d44274cc2868dc9040ac5d58d464784610234455b4e7b19c6ef/black-26.5.1-py3-none-any.whl", hash = "sha256:4ed7f7da04046d2e488437170797d3b4a4ad83906683bcb7dfc68b673bbce5e2", size = 213693, upload-time = "2026-05-18T16:53:33.964Z" },
]
[[package]] [[package]]
name = "certifi" name = "certifi"
version = "2026.5.20" version = "2026.5.20"
@@ -192,16 +273,20 @@ dependencies = [
[package.optional-dependencies] [package.optional-dependencies]
dev = [ dev = [
{ name = "black" },
{ name = "httpx" }, { name = "httpx" },
{ name = "mypy" },
{ name = "pytest" }, { name = "pytest" },
{ name = "ruff" }, { name = "ruff" },
] ]
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "black", marker = "extra == 'dev'", specifier = ">=26.5.1" },
{ name = "dnspython", specifier = ">=2.6" }, { name = "dnspython", specifier = ">=2.6" },
{ name = "fastapi", specifier = ">=0.111" }, { name = "fastapi", specifier = ">=0.111" },
{ name = "httpx", marker = "extra == 'dev'", specifier = ">=0.28" }, { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.28" },
{ name = "mypy", marker = "extra == 'dev'", specifier = ">=2.1.0" },
{ name = "pydantic", specifier = ">=2.7" }, { name = "pydantic", specifier = ">=2.7" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.2" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.2" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.8" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.8" },
@@ -209,6 +294,139 @@ requires-dist = [
] ]
provides-extras = ["dev"] provides-extras = ["dev"]
[[package]]
name = "librt"
version = "0.11.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/40/08/9e7f6b5d2b5bed6ad055cdd5925f192bb403a51280f86b56554d9d0699a2/librt-0.11.0.tar.gz", hash = "sha256:075dc3ef4458a278e0195cbf6ac9d38808d9b906c5a6c7f7f79c3888276a3fb1", size = 200139, upload-time = "2026-05-10T18:17:25.138Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fe/87/2bf31fe17587b29e3f93ec31421e2b1e1c3e349b8bf6c7c313dbad1d5340/librt-0.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:93d95bd45b7d58343d8b90d904450a545144eec19a002511163426f8ab1fae29", size = 141092, upload-time = "2026-05-10T18:15:34.795Z" },
{ url = "https://files.pythonhosted.org/packages/cf/08/5c5bf772920b7ebac6e32bc91a643e0ab3870199c0b542356d3baa83970a/librt-0.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ee278c769a713638cdacd4c0436d72156e75df3ebc0166ab2b9dc43acc386c9", size = 142035, upload-time = "2026-05-10T18:15:36.242Z" },
{ url = "https://files.pythonhosted.org/packages/06/20/662a03d254e5b000d838e8b345d83303ddb768c080fd488e40634c0fa66b/librt-0.11.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f230cb1cbc9faaa616f9a678f530ebcf186e414b6bcbd88b960e4ba1b92428d5", size = 475022, upload-time = "2026-05-10T18:15:37.56Z" },
{ url = "https://files.pythonhosted.org/packages/de/f3/aa81523e45184c6ec23dc7f63263362ec55f80a09d424c012359ecbe7e35/librt-0.11.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5d63c855d86938d9de93e265c9bd8c705b51ec494de5738340ee93767a686e4b", size = 467273, upload-time = "2026-05-10T18:15:39.182Z" },
{ url = "https://files.pythonhosted.org/packages/6b/6f/59c74b560ca8853834d5501d589c8a2519f4184f273a085ffd0f37a1cc47/librt-0.11.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:993f028be9e96a08d31df3479ac80d99be374d17f3b78e4796b3fd3c913d4e89", size = 497083, upload-time = "2026-05-10T18:15:40.634Z" },
{ url = "https://files.pythonhosted.org/packages/fe/7b/5aa4d2c9600a719401160bf7055417df0b2a47439b9d88286ce45e56b65f/librt-0.11.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:258d73a0aa66a055e65b2e4d1b8cdb23b9d132c5bb915d9547d804fcaed116cc", size = 489139, upload-time = "2026-05-10T18:15:41.934Z" },
{ url = "https://files.pythonhosted.org/packages/d6/31/9143803d7da6856a69153785768c4936864430eec0fd9461c3ea527d9922/librt-0.11.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0827efe7854718f04aaddf6496e96960a956e676fe1d0f04eb41511fd8ad06d5", size = 508442, upload-time = "2026-05-10T18:15:43.206Z" },
{ url = "https://files.pythonhosted.org/packages/2f/5a/bce08184488426bda4ccc2c4964ac048c8f68ae89bd7120082eef4233cfd/librt-0.11.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7753e57d6e12d019c0d8786f1c09c709f4c3fcc57c3887b24e36e6c06ec938b7", size = 514230, upload-time = "2026-05-10T18:15:44.761Z" },
{ url = "https://files.pythonhosted.org/packages/89/8c/bb5e213d254b7505a0e658da199d8ab719086632ce09eef311ab27976523/librt-0.11.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:11bd19822431cc21af9f27374e7ae2e58103c7d98bda823536a6c47f6bb2bb3d", size = 494231, upload-time = "2026-05-10T18:15:46.308Z" },
{ url = "https://files.pythonhosted.org/packages/9d/fb/541cdad5b1ab1300398c74c4c9a497b88e5074c21b1244c8f49731d3a284/librt-0.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:22bdf239b219d3993761a148ffa134b19e52e9989c84f845d5d7b71d70a17412", size = 537585, upload-time = "2026-05-10T18:15:47.629Z" },
{ url = "https://files.pythonhosted.org/packages/8f/f2/464bb69295c320cb06bddb4f14a4ec67934ee14b2bffb12b19fb7ab287ba/librt-0.11.0-cp311-cp311-win32.whl", hash = "sha256:46c60b61e308eb535fbd6fa622b1ee1bb2815691c1ad9c98bf7b84952ec3bc8d", size = 100509, upload-time = "2026-05-10T18:15:49.157Z" },
{ url = "https://files.pythonhosted.org/packages/6d/e7/a17ee1788f9e4fbf548c19f4afa07c92089b9e24fef6cb2410863781ef4c/librt-0.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:902e546ff044f579ff1c953ff5fce97b636fe9e3943996b2177710c6ef076f73", size = 118628, upload-time = "2026-05-10T18:15:50.345Z" },
{ url = "https://files.pythonhosted.org/packages/cc/c7/6c766214f9f9903bcfcfbef97d807af8d8f5aa3502d247858ab17582d212/librt-0.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:65ac3bc20f78aa0ee5ae84baa68917f89fef4af63e941084dd019a0d0e749f0c", size = 103122, upload-time = "2026-05-10T18:15:52.068Z" },
{ url = "https://files.pythonhosted.org/packages/8b/d0/07c77e067f0838949b43bd89232c29d72efebb9d2801a9750184eb706b71/librt-0.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b87504f1690a23b9a2cca841191a04f83895d4fc2dd04df91d82b1a04ca2ad46", size = 144147, upload-time = "2026-05-10T18:15:53.227Z" },
{ url = "https://files.pythonhosted.org/packages/7a/24/8493538fa4f62f982686398a5b8f68008138a75086abdea19ade64bf4255/librt-0.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40071fc5fe0ce8daa6de616702314a01e1250711682b0523d6ab8d4525910cb3", size = 143614, upload-time = "2026-05-10T18:15:54.657Z" },
{ url = "https://files.pythonhosted.org/packages/ff/1e/f8bad050810d9171f34a1648ed910e56814c2ba61639f2bd53c6377ae24b/librt-0.11.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:137e79445c896a0ea7b265f52d23954e05b64222ee1af69e2cb34219067cbb67", size = 485538, upload-time = "2026-05-10T18:15:56.117Z" },
{ url = "https://files.pythonhosted.org/packages/c0/fe/3594ebfbaf03084ba4b120c9ba5c3183fd938a48725e9bbe6ff0a5159ad8/librt-0.11.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:cca6644054e78746d8d4ef238681f9c34ff8b584fe6b988ecebb8db3b15e622a", size = 479623, upload-time = "2026-05-10T18:15:57.544Z" },
{ url = "https://files.pythonhosted.org/packages/b0/da/5d1876984b3746c85dbd219dbfcb73c85f54ee263fd32e5b2a632ec14571/librt-0.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5b0eea49f5562861ee8d757a32ef7d559c1d35be2aaaa1ec28941d74c9ffc8a", size = 513082, upload-time = "2026-05-10T18:15:58.805Z" },
{ url = "https://files.pythonhosted.org/packages/19/6e/55bdf5d5ca00c3e18430690bf2c953d8d3ffd3c337418173d33dec985dc9/librt-0.11.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0d1029d7e1ae1a7e647ed6fb5df8c4ce2dffefb7a9f5fd1376a4554d96dac09f", size = 508105, upload-time = "2026-05-10T18:16:00.2Z" },
{ url = "https://files.pythonhosted.org/packages/07/10/f1f23a7c595ee90ece4d35c851e5d104b1311a887ed1b4ac4c35bbd13da8/librt-0.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bc3ce6b33c5828d9e80592011a5c584cb2ce86edbc4088405f70da47dc1d1b3b", size = 522268, upload-time = "2026-05-10T18:16:01.708Z" },
{ url = "https://files.pythonhosted.org/packages/b6/02/5720f5697a7f54b78b3aefbe20df3a48cedcff1276618c4aa481177942ed/librt-0.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:936c5995f3514a42111f20099397d8177c79b4d7e70961e396c6f5a0a3566766", size = 527348, upload-time = "2026-05-10T18:16:03.496Z" },
{ url = "https://files.pythonhosted.org/packages/50/db/b4a47c6f91db4ff76348a0b3dd0cc65e090a078b765a810a62ff9434c3d3/librt-0.11.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9bc0ca6ad9381cbe8e4aa6e5726e4c80c78115a6e9723c599ed1d73e092bc49d", size = 516294, upload-time = "2026-05-10T18:16:05.173Z" },
{ url = "https://files.pythonhosted.org/packages/9e/58/9384b2f4eb1ed1d273d40948a7c5c4b2360213b402ef3be4641c06299f9c/librt-0.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:070aa8c26c0a74774317a72df8851facc7f0f012a5b406557ac56992d92e1ec8", size = 553608, upload-time = "2026-05-10T18:16:06.839Z" },
{ url = "https://files.pythonhosted.org/packages/21/7b/5aa8848a7c6a9278c79375146da1812e695754ceec5f005e6043461a7315/librt-0.11.0-cp312-cp312-win32.whl", hash = "sha256:6bf14feb84b05ae945277395451998c89c54d0def4070eb5c08de544930b245a", size = 101879, upload-time = "2026-05-10T18:16:08.103Z" },
{ url = "https://files.pythonhosted.org/packages/37/33/8a745436944947575b584231750a41417de1a38cf6a2e9251d1065651c09/librt-0.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:75672f0bc524ede266287d532d7923dbce94c7514ad07627bac3d0c6d92cc4d9", size = 119831, upload-time = "2026-05-10T18:16:09.174Z" },
{ url = "https://files.pythonhosted.org/packages/59/67/a6739ac96e28b7855808bdb0370e250606104a859750d209e5a0716fe7ab/librt-0.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:2f10cf143e4a9bb0f4f5af568a00df94a2d69ef41c2579584454bb0fe5cc642c", size = 103470, upload-time = "2026-05-10T18:16:10.369Z" },
{ url = "https://files.pythonhosted.org/packages/82/61/e59168d4d0bf2bf90f4f0caf7a001bfc60254c3af4586013b04dc3ef517b/librt-0.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:78dc31f7fdfe9c9d0eb0e8f42d139db230e826415bbcabd9f0e9faaaee909894", size = 144119, upload-time = "2026-05-10T18:16:11.771Z" },
{ url = "https://files.pythonhosted.org/packages/61/fd/caa1d60b12f7dd79ccea23054e06eeaebe266a5f52c40a6b651069200ce5/librt-0.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fa475675db22290c3158e1d42326d0f5a65f04f44a0e68c3630a25b53560fb9c", size = 143565, upload-time = "2026-05-10T18:16:13.334Z" },
{ url = "https://files.pythonhosted.org/packages/b8/a9/dc744f5c2b4978d48db970be29f22716d3413d28b14ad99740817315cf2c/librt-0.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:621db29691044bdeda22e789e482e1b0f3a985d90e3426c9c6d17606416205ea", size = 485395, upload-time = "2026-05-10T18:16:14.729Z" },
{ url = "https://files.pythonhosted.org/packages/8f/21/7f8e97a1e4dae952a5a95948f6f8507a173bc1e669f54340bba6ca1ca31b/librt-0.11.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a9010e2ed5b3a9e158c5fd966b3ab7e834bb3d3aacc8f66c91dd4b57a3799230", size = 479383, upload-time = "2026-05-10T18:16:16.321Z" },
{ url = "https://files.pythonhosted.org/packages/a6/6d/d8ee9c114bebf2c50e29ec2aa940826fccb62a645c3e4c18760987d0e16d/librt-0.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c39513d8b7477a2e1ed8c43fc21c524e8d5a0f8d4e8b7b074dbdbe7820a08e2", size = 513010, upload-time = "2026-05-10T18:16:17.647Z" },
{ url = "https://files.pythonhosted.org/packages/f0/43/0b5708af2bd30a46400e72ba6bdaa8f066f15fb9a688527e34220e8d6c06/librt-0.11.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7aef3cf1d5af86e770ab04bfd993dfc4ae8b8c17f66fb77dd4a7d50de7bbb1a3", size = 508433, upload-time = "2026-05-10T18:16:19.309Z" },
{ url = "https://files.pythonhosted.org/packages/4a/50/356187247d09013490481033183b3532b58acf8028bcb34b2b56a375c9b2/librt-0.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:557183ddc36babe46b27dd60facbd5adb4492181a5be887587d57cda6e092f21", size = 522595, upload-time = "2026-05-10T18:16:20.642Z" },
{ url = "https://files.pythonhosted.org/packages/40/e7/c6ac4240899c7f3248079d5a9900debe0dadb3fdeaf856684c987105ba47/librt-0.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:83d3e1f72bd42f6c5c0b7daec530c3f829bd02db42c70b8ddf0c2d90a2459930", size = 527255, upload-time = "2026-05-10T18:16:22.352Z" },
{ url = "https://files.pythonhosted.org/packages/eb/b5/a81322dbeedeeaf9c1ee6f001734d28a09d8383ac9e6779bc24bbd0743c6/librt-0.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4ce1f21fbe589bc1afd7872dece84fb0e1144f794a288e58a10d2c54a55c43be", size = 516847, upload-time = "2026-05-10T18:16:23.627Z" },
{ url = "https://files.pythonhosted.org/packages/ae/66/6e6323787d592b55204a42595ff1102da5115601b53a7e9ddebc889a6da5/librt-0.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b09f7044ea2b64c9da42fd3d335666518cfd1c6e8a182c95da73d0214b41e", size = 553920, upload-time = "2026-05-10T18:16:25.025Z" },
{ url = "https://files.pythonhosted.org/packages/9c/21/623f8ca230857102066d9ca8c6c1734995908c4d0d1bee7bb2ef0021cb33/librt-0.11.0-cp313-cp313-win32.whl", hash = "sha256:78fddc31cd4d3caa897ad5d31f856b1faadc9474021ad6cb182b9018793e254e", size = 101898, upload-time = "2026-05-10T18:16:26.649Z" },
{ url = "https://files.pythonhosted.org/packages/b3/1d/b4ebd44dd723f768469007515cb92251e0ae286c94c140f374801140fa74/librt-0.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ca8aa88751a775870b764e93bad5135385f563cb8dcee399abf034ea4d3cb47", size = 119812, upload-time = "2026-05-10T18:16:27.859Z" },
{ url = "https://files.pythonhosted.org/packages/3b/e4/b2f4ca7965ca373b491cdb4bc25cdb30c1649ca81a8782056a83850292a9/librt-0.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:96f044bb325fd9cf1a723015638c219e9143f0dfbc0ca54c565df2b7fc748b44", size = 103448, upload-time = "2026-05-10T18:16:29.066Z" },
{ url = "https://files.pythonhosted.org/packages/29/eb/dbce197da4e227779e56b5735f2decc3eb36e55a1cdbf1bd65d6639d76c1/librt-0.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4a017a95e5837dc15a8c5661d60e05daa96b90908b1aa6b7acdf443cd25c8ebd", size = 143345, upload-time = "2026-05-10T18:16:30.674Z" },
{ url = "https://files.pythonhosted.org/packages/76/a3/254bebd0c11c8ba684018efb8006ff22e466abce445215cca6c778e7d9de/librt-0.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b1ecbd9819deccc39b7542bf4d2a740d8a620694d39989e58661d3763458f8d4", size = 143131, upload-time = "2026-05-10T18:16:32.037Z" },
{ url = "https://files.pythonhosted.org/packages/f1/3f/f77d6122d21ac7bf6ae8a7dfced1bd2a7ac545d3273ebdcaf8042f6d619f/librt-0.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7da327dacd7be8f8ec36547373550744a3cc0e536d54665cd83f8bcd961200e8", size = 477024, upload-time = "2026-05-10T18:16:33.493Z" },
{ url = "https://files.pythonhosted.org/packages/ac/0a/2c996dadebaa7d9bbbd43ef2d4f3e66b6da545f838a41694ef6172cebec8/librt-0.11.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0dc56b1f8d06e60db362cc3fdae206681817f86ce4725d34511473487f12a34b", size = 474221, upload-time = "2026-05-10T18:16:34.864Z" },
{ url = "https://files.pythonhosted.org/packages/0a/7e/f5d92af8486b8272c23b3e686b46ff72d89c8169585eb61eef01a2ac7147/librt-0.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05fb8fb2ab90e21c8d12ea240d744ad514da9baf381ebfa70d91d20d21713175", size = 505174, upload-time = "2026-05-10T18:16:36.705Z" },
{ url = "https://files.pythonhosted.org/packages/af/1a/cb0734fe86398eb33193ab753b7326255c74cac5eb09e76b9b16536e7adb/librt-0.11.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cae74872be221df4374d10fec61f93ed1513b9546ea84f2c0bf73ab3e9bd0b03", size = 497216, upload-time = "2026-05-10T18:16:38.418Z" },
{ url = "https://files.pythonhosted.org/packages/18/06/094820f91558b66e29943c0ec41c9914f460f48dd51fc503c3101e10842d/librt-0.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:32bcc918c0148eb7e3d57385125bac7e5f9e4359d05f07448b09f6f778c2f31c", size = 513921, upload-time = "2026-05-10T18:16:39.848Z" },
{ url = "https://files.pythonhosted.org/packages/0b/c2/00de9018871a282f530cacb457d5ec0428f6ac7e6fedde9aff7468d9fb04/librt-0.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f9743fc99135d5f78d2454435615f6dec0473ca507c26ce9d92b10b562a280d3", size = 520850, upload-time = "2026-05-10T18:16:41.471Z" },
{ url = "https://files.pythonhosted.org/packages/51/9d/64631832348fd1834fb3a61b996434edddaaf25a31d03b0a76273159d2cf/librt-0.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5ba067f4aadae8fda802d91d2124c90c42195ff32d9161d3549e6d05cfe26f96", size = 504237, upload-time = "2026-05-10T18:16:43.15Z" },
{ url = "https://files.pythonhosted.org/packages/a5/ec/ae5525eb16edc827a044e7bb8777a455ff95d4bca9379e7e6bddd7383647/librt-0.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:de3bf945454d032f9e390b85c4072e0a0570bf825421c8be0e71209fa65e1abe", size = 546261, upload-time = "2026-05-10T18:16:44.408Z" },
{ url = "https://files.pythonhosted.org/packages/5a/09/adce371f27ca039411da9659f7430fcc2ba6cd0c7b3e4467a0f091be7fa9/librt-0.11.0-cp314-cp314-win32.whl", hash = "sha256:d2277a05f6dcb9fd13db9566aac4fabd68c3ea1ea46ee5567d4eef8efa495a2f", size = 96965, upload-time = "2026-05-10T18:16:46.039Z" },
{ url = "https://files.pythonhosted.org/packages/d6/ee/8ac720d98548f173c7ce2e632a7ca94673f74cacd5c8162a84af5b35958a/librt-0.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:ab73e8db5e3f564d812c1f5c3a175930a5f9bc96ccb5e3b22a34d7858b401cf7", size = 115151, upload-time = "2026-05-10T18:16:47.133Z" },
{ url = "https://files.pythonhosted.org/packages/94/20/c900cf14efeb09b6bef2b2dff20779f73464b97fd58d1c6bccc379588ae3/librt-0.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:aea3caa317752e3a466fa8af45d91ee0ea8c7fdd96e42b0a8dd9b76a7931eba1", size = 98850, upload-time = "2026-05-10T18:16:48.597Z" },
{ url = "https://files.pythonhosted.org/packages/0c/71/944bfe4b64e12abffcd3c15e1cce07f72f3d55655083786285f4dedeb532/librt-0.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d1b36540d7aaf9b9101b3a6f376c8d8e9f7a9aec93ed05918f2c69d493ffef72", size = 151138, upload-time = "2026-05-10T18:16:49.839Z" },
{ url = "https://files.pythonhosted.org/packages/b6/10/99e64a5c86989357fda078c8143c533389585f6473b7439172dd8f3b3b2d/librt-0.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:efbb343ab2ce3540f4ecbe6315d677ed70f37cd9a72b1e58066c918ca83acbaa", size = 151976, upload-time = "2026-05-10T18:16:51.062Z" },
{ url = "https://files.pythonhosted.org/packages/21/31/5072ad880946d83e5ea4147d6d018c78eefce85b77819b19bdd0ee229435/librt-0.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0dd688aab3f7914d3e6e5e3554978e0383312fb8e771d84be008a35b9ee548", size = 557927, upload-time = "2026-05-10T18:16:52.632Z" },
{ url = "https://files.pythonhosted.org/packages/5e/8d/70b5fb7cfbab60edbe7381614ab985da58e144fbf465c86d44c95f43cdca/librt-0.11.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f5fb36b8c6c63fdcbb1d526d94c0d1331610d43f4118cc1beb4efef4f3faacb2", size = 539698, upload-time = "2026-05-10T18:16:53.934Z" },
{ url = "https://files.pythonhosted.org/packages/fa/a3/ba3495a0b3edbd24a4cae0d1d3c64f39a9fc45d06e812101289b50c1a619/librt-0.11.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4a9a237d13addb93715b6fee74023d5ee3469b53fce527626c0e088aa585805f", size = 577162, upload-time = "2026-05-10T18:16:55.589Z" },
{ url = "https://files.pythonhosted.org/packages/f7/db/36e25fb81f99937ff1b96612a1dc9fd66f039cb9cc3aee12c01fac31aab9/librt-0.11.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5ddd17bd87b2c56ddd60e546a7984a2e64c4e8eab92fb4cf3830a48ad5469d51", size = 566494, upload-time = "2026-05-10T18:16:56.975Z" },
{ url = "https://files.pythonhosted.org/packages/33/0d/3f622b47f0b013eeb9cf4cc07ae9bfe378d832a4eec998b2b209fe84244d/librt-0.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd43992b4473d42f12ff9e68326079f0696d9d4e6000e8f39a0238d482ba6ee2", size = 596858, upload-time = "2026-05-10T18:16:58.374Z" },
{ url = "https://files.pythonhosted.org/packages/a9/02/71b90bc93039c46a2000651f6ad60122b114c8f54c4ad306e0e96f5b75ad/librt-0.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:f8e3e8056dd674e279741485e2e512d6e9a751c7455809d0114e6ebf8d781085", size = 590318, upload-time = "2026-05-10T18:16:59.676Z" },
{ url = "https://files.pythonhosted.org/packages/04/04/418cb3f75621e2b761fb1ab0f017f4d70a1a72a6e7c74ee4f7e8d198c2f3/librt-0.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c1f708d8ae9c56cf38a903c44297243d2ec83fd82b396b977e0144a3e76217e3", size = 575115, upload-time = "2026-05-10T18:17:01.007Z" },
{ url = "https://files.pythonhosted.org/packages/cc/2c/5a2183ac58dd911f26b5d7e7d7d8f1d87fcecdddd99d6c12169a258ff62c/librt-0.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0add982e0e7b9fc14cf4b33789d5f13f66581889b88c2f58099f6ce8f92617bd", size = 617918, upload-time = "2026-05-10T18:17:02.682Z" },
{ url = "https://files.pythonhosted.org/packages/15/1f/dc6771a52592a4451be6effa200cbfc9cec61e4393d3033d81a9d307961d/librt-0.11.0-cp314-cp314t-win32.whl", hash = "sha256:2b481d846ac894c4e8403c5fd0e87c5d11d6499e404b474602508a224ff531c8", size = 103562, upload-time = "2026-05-10T18:17:03.99Z" },
{ url = "https://files.pythonhosted.org/packages/62/4a/7d1415567027286a75ba1093ec4aca11f073e0f559c530cf3e0a757ad55c/librt-0.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:28edb433edde181112a908c78907af28f964eabc15f4dd16c9d66c834302677c", size = 124327, upload-time = "2026-05-10T18:17:05.465Z" },
{ url = "https://files.pythonhosted.org/packages/ce/62/b40b382fa0c66fee1478073eb8db352a4a6beda4a1adccf1df911d8c289c/librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253", size = 102572, upload-time = "2026-05-10T18:17:06.809Z" },
]
[[package]]
name = "mypy"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "ast-serialize" },
{ name = "librt", marker = "platform_python_implementation != 'PyPy'" },
{ name = "mypy-extensions" },
{ name = "pathspec" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/82/15/cca9d88503549ed6fedeaa1d448cdddd542ee8a490232d732e278036fbf2/mypy-2.1.0.tar.gz", hash = "sha256:81e76ad12c2d804512e9b13240d1588316531bfba07558286078bfbce9613633", size = 3898359, upload-time = "2026-05-11T18:37:36.237Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0a/a1/639f3024794a2a15899cb90707fe02e044c4412794c39c5769fd3df2e2ef/mypy-2.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a683016b16fe2f572dc04c72be7ee0504ac1605a265d0200f5cea695fb788f41", size = 14691685, upload-time = "2026-05-11T18:33:27.973Z" },
{ url = "https://files.pythonhosted.org/packages/3b/08/9a585dea4325f20d8b80dc78623fa50d1fd2173b710f6237afd6ba6ab39b/mypy-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1a293c534adb55271fef24a26da04b855540a8c13cc07bc5917b9fd2c394f2ca", size = 13555165, upload-time = "2026-05-11T18:32:16.107Z" },
{ url = "https://files.pythonhosted.org/packages/81/dc/7c42cc9c6cb01e8eb09961f1f738741d3e9c7e9d5c5b30ec69222625cd5f/mypy-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7406f4d048e71e576f5356d317e5b0a9e666dfd966bd99f9d14ca06e1a341538", size = 13994376, upload-time = "2026-05-11T18:32:39.256Z" },
{ url = "https://files.pythonhosted.org/packages/d4/fa/285946c33bce716e082c11dfeee9ee196eaf1f5042efb3581a31f9f205e4/mypy-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e0210d626fc8b31ccc90233754c7bc90e1f43205e85d96387f7db1285b55c398", size = 14864618, upload-time = "2026-05-11T18:34:49.765Z" },
{ url = "https://files.pythonhosted.org/packages/2b/83/82397f48af6c27e295d57979ded8490c9829040152cf7571b2f026aeb9a0/mypy-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3712c20deed54e814eaaa825603bada8ea1c390670a397c95b98405347acc563", size = 15102063, upload-time = "2026-05-11T18:34:05.855Z" },
{ url = "https://files.pythonhosted.org/packages/40/68/b02dec39057b88eb03dc0aa854732e26e8361f34f9d0e20c7614967d1eba/mypy-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:fcaa0e479066e31f7cceb6a3bea39cb22b2ff51a6b2f24f193d19179ba17c389", size = 11060564, upload-time = "2026-05-11T18:35:36.494Z" },
{ url = "https://files.pythonhosted.org/packages/cf/a8/ea3dcbef31f99b634f2ee23bb0321cbc8c1b388b76a861eb849f13c347dc/mypy-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:0b1a5260c95aa443083f9ed3592662941951bca3d4ca224a5dc517c38b7cf666", size = 9966983, upload-time = "2026-05-11T18:37:14.139Z" },
{ url = "https://files.pythonhosted.org/packages/95/b1/55861beb5c339b44f9a2ba92df9e2cb1eeb4ae1eee674cdf7772c797778b/mypy-2.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:244358bf1c0da7722230bce60683d52e8e9fd030554926f15b747a84efb5b3af", size = 14874381, upload-time = "2026-05-11T18:37:31.784Z" },
{ url = "https://files.pythonhosted.org/packages/0b/b3/b7f770114b7d0ac92d0f76e8d93c2780844a70488a90e91821927850da86/mypy-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4ec7c57657493c7a75534df2751c8ae2cda383c16ecc55d2106c54476b1b16f6", size = 13665501, upload-time = "2026-05-11T18:34:23.063Z" },
{ url = "https://files.pythonhosted.org/packages/b6/f3/8ae2037967e2126689a0c11d99e2b707134a565191e92c60ca2572aec60a/mypy-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8161b6ff4392410023224f0969d17db93e1e154bc3e4ba62598e720723ae211", size = 14045750, upload-time = "2026-05-11T18:31:48.151Z" },
{ url = "https://files.pythonhosted.org/packages/a0/32/615eb5911859e43d054941b0d0a7d06cfa2870eba86529cf385b052b111c/mypy-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf03e12003084a67395184d3eb8cbd6a489dc3655b5664b28c210a9e2403ab0b", size = 15061630, upload-time = "2026-05-11T18:37:06.898Z" },
{ url = "https://files.pythonhosted.org/packages/d4/03/4eafbfff8bfab1b87082741eae6e6a624028c984e6708b73bce2a8570c9d/mypy-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:20509760fd791c51579d573153407d226385ec1f8bcce55d730b354f3336bc22", size = 15288831, upload-time = "2026-05-11T18:31:18.07Z" },
{ url = "https://files.pythonhosted.org/packages/99/ee/919661478e5891a3c96e549c036e467e64563ab85995b10c53c8358e16a3/mypy-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:6753d0c1fdd6b1a23b9e4f283ce80b2153b724adcb2653b20b85a8a28ac6436b", size = 11135228, upload-time = "2026-05-11T18:34:31.23Z" },
{ url = "https://files.pythonhosted.org/packages/24/0a/6a12b9782ca0831a553192f351679f4548abc9d19a7cc93bb7feb02084c7/mypy-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:98ebb6589bb3b6d0c6f0c459d53ca55b8091fbc13d277c4041c885392e8195e8", size = 10040684, upload-time = "2026-05-11T18:36:48.199Z" },
{ url = "https://files.pythonhosted.org/packages/6e/dd/c7191469c777f07689c032a8f7326e393ea34c92d6d76eb7ce5ba57ea66d/mypy-2.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:35aac3bb114e03888f535d5eb51b8bafbb3266586b599da1940f9b1be3ec5bd5", size = 14852174, upload-time = "2026-05-11T18:31:38.929Z" },
{ url = "https://files.pythonhosted.org/packages/55/8c/aed55408879043d72bb9135f4d0d19a02b886dd569631e113e3d2706cb8d/mypy-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8de55a8c861f2a49331f807be98d90caeceeef520bde13d43a160207f8af613e", size = 13651542, upload-time = "2026-05-11T18:36:04.636Z" },
{ url = "https://files.pythonhosted.org/packages/3a/8e/f371a824b1f1fa8ea6e3dbb8703d232977d572be2329554a3bc4d960302f/mypy-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5fdf2941a07434af755837d9880f7d7d25f1dacb1af9dcd4b9b66f2220a3024e", size = 14033929, upload-time = "2026-05-11T18:35:55.742Z" },
{ url = "https://files.pythonhosted.org/packages/94/21/f54be870d6dd53a82c674407e0f8eed7174b05ec78d42e5abd7b42e84fd5/mypy-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e195b817c13f02352a9c124301f9f30f078405444679b6753c1b96b6eed37285", size = 15039200, upload-time = "2026-05-11T18:33:10.281Z" },
{ url = "https://files.pythonhosted.org/packages/17/99/bf21748626a40ce59fd29a39386ab46afec88b7bd2f0fa6c3a97c995523f/mypy-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5431d42af987ebd92ba2f71d45c85ed41d8e6ca9f5fd209a69f68f707d2469e5", size = 15272690, upload-time = "2026-05-11T18:32:07.205Z" },
{ url = "https://files.pythonhosted.org/packages/d6/d7/9e90d2cf47100bea550ed2bc7b0d4de3a62181d84d5e37da0003e8462637/mypy-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:767fe8c66dc3e01e19e1737d4c38ebefead16125e1b8e58ad421903b376f5c65", size = 11147435, upload-time = "2026-05-11T18:33:56.477Z" },
{ url = "https://files.pythonhosted.org/packages/ec/46/e5c449e858798e35ffc90946282a27c62a77be743fe17480e4977374eb91/mypy-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:ecfe70d43775ab99562ab128ce49854a362044c9f894961f68f898c23cb7429d", size = 10035052, upload-time = "2026-05-11T18:32:30.049Z" },
{ url = "https://files.pythonhosted.org/packages/b0/ca/b279a672e874aedd5498ae25f722dacc8aa86bbffb939b3f97cbb1cf6686/mypy-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7354c5a7f69d9345c3d6e69921d57088eea3ddeeb6b20d34c1b3855b02c36ec2", size = 14848422, upload-time = "2026-05-11T18:35:45.984Z" },
{ url = "https://files.pythonhosted.org/packages/27/e6/3efe56c631d959b9b4454e208b0ac4b7f4f58b404c89f8bec7b49efdfc21/mypy-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:49890d4f76ac9e06ec117f9e09f3174da70a620a0c300953d8595c926e80947f", size = 13677374, upload-time = "2026-05-11T18:36:57.188Z" },
{ url = "https://files.pythonhosted.org/packages/84/7f/8107ea87a44fd1f1b59882442f033c9c3488c127201b1d1d15f1cbd6022e/mypy-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:761be68e023ef5d94678772396a8af1220030f80837a3afd8d0aef3b419666f4", size = 14055743, upload-time = "2026-05-11T18:35:18.361Z" },
{ url = "https://files.pythonhosted.org/packages/51/4d/b6d34db183133b83761b9199a82d31557cdbb70a380d8c3b3438e11882a3/mypy-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c90345fc182dc363b891350457ec69c35140858538f38b4540845afcc32b1aef", size = 15020937, upload-time = "2026-05-11T18:34:59.618Z" },
{ url = "https://files.pythonhosted.org/packages/ff/d7/f08360c691d758acb02f45022c34d98b92892f4ea756644e1000d4b9f3d8/mypy-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b84802e7b5a6daf1f5e15bc9fcd7ddae77be13981ffab037f1c67bb84d67d135", size = 15253371, upload-time = "2026-05-11T18:36:41.081Z" },
{ url = "https://files.pythonhosted.org/packages/67/1b/09460a13719530a19bce27bd3bc8449e83569dd2ba7faf51c9c3c30c0b61/mypy-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:022c771234936ceac541ebaf836fe9e2abeb3f5e09aff21588fe543ff006fe21", size = 11326429, upload-time = "2026-05-11T18:34:13.526Z" },
{ url = "https://files.pythonhosted.org/packages/40/62/75dbf0f82f7b6680340efc614af29dd0b3c17b8a4f1cd09b8bd2fd6bc814/mypy-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:498207db725cec88829a6a5c2fc771205fd043719ef98bc49aba8fb9fc4e6d57", size = 10218799, upload-time = "2026-05-11T18:32:23.491Z" },
{ url = "https://files.pythonhosted.org/packages/b2/66/caca04ed7d972fb6eb6dd1ccd6df1de5c38fae8c5b3dc1c4e8e0d85ee6b9/mypy-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d5e5cad0efeba72b93cd17490cc0d69c5ac9ca132994fe3fb0314808aeeb83e", size = 15923458, upload-time = "2026-05-11T18:35:28.64Z" },
{ url = "https://files.pythonhosted.org/packages/ed/52/2d90cbe49d014b13ed7ff337930c30bad35893fe38a1e4641e756bb62191/mypy-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ff715050c127d724fd260a2e666e7747fdd83511c0c47d449d98238970aef780", size = 14757697, upload-time = "2026-05-11T18:36:14.208Z" },
{ url = "https://files.pythonhosted.org/packages/ac/37/d98f4a14e081b238992d0ed96b6d39c7cc0148c9699eb71eaa68629665ea/mypy-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82208da9e09414d520e912d3e462d454854bed0810b71540bb016dcbca7308fd", size = 15405638, upload-time = "2026-05-11T18:33:48.249Z" },
{ url = "https://files.pythonhosted.org/packages/a3/c2/15c46613b24a84fad2aea1248bf9619b99c2767ae9071fe224c179a0b7d4/mypy-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e79ebc1b904b84f0310dff7469655a9c36c7a68bddb37bdd42b67a332df61d08", size = 16215852, upload-time = "2026-05-11T18:32:50.296Z" },
{ url = "https://files.pythonhosted.org/packages/5c/90/9c16a57f482c76d25f6379762b56bbf65c711d8158cf271fb2802cfb0640/mypy-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e583edc957cfb0deb142079162ae826f58449b116c1d442f2d91c69d9fced081", size = 16452695, upload-time = "2026-05-11T18:33:38.182Z" },
{ url = "https://files.pythonhosted.org/packages/0f/4c/215a4eeb63cacc5f17f516691ea7285d11e249802b942476bff15922a314/mypy-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b33b6cd332695bba180d55e717a79d3038e479a2c49cc5eb3d53603409b9a5d7", size = 12866622, upload-time = "2026-05-11T18:34:39.945Z" },
{ url = "https://files.pythonhosted.org/packages/4b/50/1043e1db5f455ffe4c9ab22747cd8ca2bc492b1e4f4e21b130a44ee2b217/mypy-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:4f910fe825376a7b66ef7ca8c98e5a149e8cd64c19ae71d84047a74ee060d4e6", size = 10610798, upload-time = "2026-05-11T18:36:31.444Z" },
{ url = "https://files.pythonhosted.org/packages/0d/2a/13ca1f292f6db1b98ff495ef3467736b331621c5917cad984b7043e7348d/mypy-2.1.0-py3-none-any.whl", hash = "sha256:a663814603a5c563fb87a4f96fb473eeb30d1f5a4885afcf44f9db000a366289", size = 2693302, upload-time = "2026-05-11T18:31:29.246Z" },
]
[[package]]
name = "mypy-extensions"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
]
[[package]] [[package]]
name = "packaging" name = "packaging"
version = "26.2" version = "26.2"
@@ -218,6 +436,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
] ]
[[package]]
name = "pathspec"
version = "1.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" },
]
[[package]]
name = "platformdirs"
version = "4.9.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" },
]
[[package]] [[package]]
name = "pluggy" name = "pluggy"
version = "1.6.0" version = "1.6.0"
@@ -378,6 +614,40 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
] ]
[[package]]
name = "pytokens"
version = "0.4.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b6/34/b4e015b99031667a7b960f888889c5bd34ef585c85e1cb56a594b92836ac/pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a", size = 23015, upload-time = "2026-01-30T01:03:45.924Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3d/92/790ebe03f07b57e53b10884c329b9a1a308648fc083a6d4a39a10a28c8fc/pytokens-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d70e77c55ae8380c91c0c18dea05951482e263982911fc7410b1ffd1dadd3440", size = 160864, upload-time = "2026-01-30T01:02:57.882Z" },
{ url = "https://files.pythonhosted.org/packages/13/25/a4f555281d975bfdd1eba731450e2fe3a95870274da73fb12c40aeae7625/pytokens-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a58d057208cb9075c144950d789511220b07636dd2e4708d5645d24de666bdc", size = 248565, upload-time = "2026-01-30T01:02:59.912Z" },
{ url = "https://files.pythonhosted.org/packages/17/50/bc0394b4ad5b1601be22fa43652173d47e4c9efbf0044c62e9a59b747c56/pytokens-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b49750419d300e2b5a3813cf229d4e5a4c728dae470bcc89867a9ad6f25a722d", size = 260824, upload-time = "2026-01-30T01:03:01.471Z" },
{ url = "https://files.pythonhosted.org/packages/4e/54/3e04f9d92a4be4fc6c80016bc396b923d2a6933ae94b5f557c939c460ee0/pytokens-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d9907d61f15bf7261d7e775bd5d7ee4d2930e04424bab1972591918497623a16", size = 264075, upload-time = "2026-01-30T01:03:04.143Z" },
{ url = "https://files.pythonhosted.org/packages/d1/1b/44b0326cb5470a4375f37988aea5d61b5cc52407143303015ebee94abfd6/pytokens-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:ee44d0f85b803321710f9239f335aafe16553b39106384cef8e6de40cb4ef2f6", size = 103323, upload-time = "2026-01-30T01:03:05.412Z" },
{ url = "https://files.pythonhosted.org/packages/41/5d/e44573011401fb82e9d51e97f1290ceb377800fb4eed650b96f4753b499c/pytokens-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:140709331e846b728475786df8aeb27d24f48cbcf7bcd449f8de75cae7a45083", size = 160663, upload-time = "2026-01-30T01:03:06.473Z" },
{ url = "https://files.pythonhosted.org/packages/f0/e6/5bbc3019f8e6f21d09c41f8b8654536117e5e211a85d89212d59cbdab381/pytokens-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d6c4268598f762bc8e91f5dbf2ab2f61f7b95bdc07953b602db879b3c8c18e1", size = 255626, upload-time = "2026-01-30T01:03:08.177Z" },
{ url = "https://files.pythonhosted.org/packages/bf/3c/2d5297d82286f6f3d92770289fd439956b201c0a4fc7e72efb9b2293758e/pytokens-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24afde1f53d95348b5a0eb19488661147285ca4dd7ed752bbc3e1c6242a304d1", size = 269779, upload-time = "2026-01-30T01:03:09.756Z" },
{ url = "https://files.pythonhosted.org/packages/20/01/7436e9ad693cebda0551203e0bf28f7669976c60ad07d6402098208476de/pytokens-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ad948d085ed6c16413eb5fec6b3e02fa00dc29a2534f088d3302c47eb59adf9", size = 268076, upload-time = "2026-01-30T01:03:10.957Z" },
{ url = "https://files.pythonhosted.org/packages/2e/df/533c82a3c752ba13ae7ef238b7f8cdd272cf1475f03c63ac6cf3fcfb00b6/pytokens-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:3f901fe783e06e48e8cbdc82d631fca8f118333798193e026a50ce1b3757ea68", size = 103552, upload-time = "2026-01-30T01:03:12.066Z" },
{ url = "https://files.pythonhosted.org/packages/cb/dc/08b1a080372afda3cceb4f3c0a7ba2bde9d6a5241f1edb02a22a019ee147/pytokens-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8bdb9d0ce90cbf99c525e75a2fa415144fd570a1ba987380190e8b786bc6ef9b", size = 160720, upload-time = "2026-01-30T01:03:13.843Z" },
{ url = "https://files.pythonhosted.org/packages/64/0c/41ea22205da480837a700e395507e6a24425151dfb7ead73343d6e2d7ffe/pytokens-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5502408cab1cb18e128570f8d598981c68a50d0cbd7c61312a90507cd3a1276f", size = 254204, upload-time = "2026-01-30T01:03:14.886Z" },
{ url = "https://files.pythonhosted.org/packages/e0/d2/afe5c7f8607018beb99971489dbb846508f1b8f351fcefc225fcf4b2adc0/pytokens-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1", size = 268423, upload-time = "2026-01-30T01:03:15.936Z" },
{ url = "https://files.pythonhosted.org/packages/68/d4/00ffdbd370410c04e9591da9220a68dc1693ef7499173eb3e30d06e05ed1/pytokens-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4", size = 266859, upload-time = "2026-01-30T01:03:17.458Z" },
{ url = "https://files.pythonhosted.org/packages/a7/c9/c3161313b4ca0c601eeefabd3d3b576edaa9afdefd32da97210700e47652/pytokens-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78", size = 103520, upload-time = "2026-01-30T01:03:18.652Z" },
{ url = "https://files.pythonhosted.org/packages/8f/a7/b470f672e6fc5fee0a01d9e75005a0e617e162381974213a945fcd274843/pytokens-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321", size = 160821, upload-time = "2026-01-30T01:03:19.684Z" },
{ url = "https://files.pythonhosted.org/packages/80/98/e83a36fe8d170c911f864bfded690d2542bfcfacb9c649d11a9e6eb9dc41/pytokens-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa", size = 254263, upload-time = "2026-01-30T01:03:20.834Z" },
{ url = "https://files.pythonhosted.org/packages/0f/95/70d7041273890f9f97a24234c00b746e8da86df462620194cef1d411ddeb/pytokens-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d", size = 268071, upload-time = "2026-01-30T01:03:21.888Z" },
{ url = "https://files.pythonhosted.org/packages/da/79/76e6d09ae19c99404656d7db9c35dfd20f2086f3eb6ecb496b5b31163bad/pytokens-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f66a6bbe741bd431f6d741e617e0f39ec7257ca1f89089593479347cc4d13324", size = 271716, upload-time = "2026-01-30T01:03:23.633Z" },
{ url = "https://files.pythonhosted.org/packages/79/37/482e55fa1602e0a7ff012661d8c946bafdc05e480ea5a32f4f7e336d4aa9/pytokens-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:b35d7e5ad269804f6697727702da3c517bb8a5228afa450ab0fa787732055fc9", size = 104539, upload-time = "2026-01-30T01:03:24.788Z" },
{ url = "https://files.pythonhosted.org/packages/30/e8/20e7db907c23f3d63b0be3b8a4fd1927f6da2395f5bcc7f72242bb963dfe/pytokens-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8fcb9ba3709ff77e77f1c7022ff11d13553f3c30299a9fe246a166903e9091eb", size = 168474, upload-time = "2026-01-30T01:03:26.428Z" },
{ url = "https://files.pythonhosted.org/packages/d6/81/88a95ee9fafdd8f5f3452107748fd04c24930d500b9aba9738f3ade642cc/pytokens-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79fc6b8699564e1f9b521582c35435f1bd32dd06822322ec44afdeba666d8cb3", size = 290473, upload-time = "2026-01-30T01:03:27.415Z" },
{ url = "https://files.pythonhosted.org/packages/cf/35/3aa899645e29b6375b4aed9f8d21df219e7c958c4c186b465e42ee0a06bf/pytokens-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d31b97b3de0f61571a124a00ffe9a81fb9939146c122c11060725bd5aea79975", size = 303485, upload-time = "2026-01-30T01:03:28.558Z" },
{ url = "https://files.pythonhosted.org/packages/52/a0/07907b6ff512674d9b201859f7d212298c44933633c946703a20c25e9d81/pytokens-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:967cf6e3fd4adf7de8fc73cd3043754ae79c36475c1c11d514fc72cf5490094a", size = 306698, upload-time = "2026-01-30T01:03:29.653Z" },
{ url = "https://files.pythonhosted.org/packages/39/2a/cbbf9250020a4a8dd53ba83a46c097b69e5eb49dd14e708f496f548c6612/pytokens-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:584c80c24b078eec1e227079d56dc22ff755e0ba8654d8383b2c549107528918", size = 116287, upload-time = "2026-01-30T01:03:30.912Z" },
{ url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" },
]
[[package]] [[package]]
name = "pyyaml" name = "pyyaml"
version = "6.0.3" version = "6.0.3"