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. |
```bash
black --check karldns tests
ruff check karldns tests
mypy karldns
pytest
python -m compileall karldns
```
+6 -3
View File
@@ -4,6 +4,7 @@ import json
import urllib.error
import urllib.parse
import urllib.request
from typing import Any, cast
from ..models import UpdateResult
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)
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})
response = _cloudflare_request(settings, f"/dns_records?{query}")
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:
@@ -69,7 +72,7 @@ def _cloudflare_request(
path: str,
method: str = "GET",
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}"
body = json.dumps(payload).encode("utf-8") if payload is not None else None
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),
) -> str:
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
else None
)
@@ -138,7 +140,13 @@ def make_router(admin_path: str = "/admin") -> APIRouter:
"router update URL rotated by admin",
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":
rotated = await run_in_threadpool(store.rotate_management_slug, hostname)
await run_in_threadpool(
@@ -150,7 +158,14 @@ def make_router(admin_path: str = "/admin") -> APIRouter:
"dashboard link rotated by admin",
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:
raise HTTPException(status_code=400, detail="invalid rotation action")
page = await run_in_threadpool(render_admin, service, settings, store, None, notice)
+25 -6
View File
@@ -16,8 +16,12 @@ from ..schemas import (
HostnameCreateResponse,
HostnameRotateUpdateResponse,
HostnameStatusResponse,
_admin_cleanup_run,
_admin_hostname,
_admin_metrics,
_admin_record,
_admin_scheduled_task,
_admin_update_event,
_create_response,
_hostname_rotate_update_response,
_hostname_status,
@@ -62,7 +66,11 @@ async def read_hostname(
if not account or account["disabled"]:
raise HTTPException(status_code=404, detail="not found")
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)
@@ -149,9 +157,9 @@ async def read_admin(
),
metrics=_admin_metrics(metrics),
hostnames=[_admin_hostname(account) for account in accounts],
update_events=events,
cleanup_runs=cleanup_runs,
scheduled_tasks=scheduled_tasks,
update_events=[_admin_update_event(event) for event in events],
cleanup_runs=[_admin_cleanup_run(cleanup_run) for cleanup_run in cleanup_runs],
scheduled_tasks=[_admin_scheduled_task(task) for task in scheduled_tasks],
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
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(
@@ -224,4 +238,9 @@ async def list_admin_records(
) -> AdminRecordsResponse:
items = await run_in_threadpool(store.list_records_page, limit, offset)
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:
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)
@@ -25,50 +34,62 @@ MINIFIED_ADMIN_CSS = _minify_css(ADMIN_CSS)
def favicon() -> FileResponse:
return FileResponse(ASSET_DIR / "favicon.ico", media_type="image/x-icon", headers=ASSET_CACHE_HEADERS)
@router.get("/bootstrap-subset.css")
def bootstrap_subset_css() -> FileResponse:
return FileResponse(ASSET_DIR / "bootstrap-subset.css", media_type="text/css", headers=ASSET_CACHE_HEADERS)
@router.get("/favicon-16x16.png")
def favicon_16_png() -> FileResponse:
return FileResponse(ASSET_DIR / "favicon-16x16.png", media_type="image/png", headers=ASSET_CACHE_HEADERS)
@router.get("/favicon-32x32.png")
def favicon_32_png() -> FileResponse:
return FileResponse(ASSET_DIR / "favicon-32x32.png", media_type="image/png", headers=ASSET_CACHE_HEADERS)
@router.get("/apple-touch-icon.png")
def apple_touch_icon_png() -> FileResponse:
return FileResponse(ASSET_DIR / "apple-touch-icon.png", media_type="image/png", headers=ASSET_CACHE_HEADERS)
@router.get("/logo.png")
def logo_png() -> FileResponse:
return FileResponse(ASSET_DIR / "logo.png", media_type="image/png", headers=ASSET_CACHE_HEADERS)
@router.get("/logo.webp")
def logo_webp() -> FileResponse:
return FileResponse(ASSET_DIR / "logo.webp", media_type="image/webp", headers=ASSET_CACHE_HEADERS)
@router.get("/logo-nav.webp")
def logo_nav_webp() -> FileResponse:
return FileResponse(ASSET_DIR / "logo-nav.webp", media_type="image/webp", headers=ASSET_CACHE_HEADERS)
@router.get("/logo-192.png")
def logo_192_png() -> FileResponse:
return FileResponse(ASSET_DIR / "logo-192.png", media_type="image/png", headers=ASSET_CACHE_HEADERS)
@router.get("/logo-512.png")
def logo_512_png() -> FileResponse:
return FileResponse(ASSET_DIR / "logo-512.png", media_type="image/png", headers=ASSET_CACHE_HEADERS)
@router.get("/social-preview.png")
def social_preview_png() -> FileResponse:
return FileResponse(ASSET_DIR / "social-preview.png", media_type="image/png", headers=ASSET_CACHE_HEADERS)
@router.get("/social-preview.webp")
def social_preview_webp() -> FileResponse:
return FileResponse(ASSET_DIR / "social-preview.webp", media_type="image/webp", headers=ASSET_CACHE_HEADERS)
@router.get("/site.webmanifest")
def site_webmanifest() -> Response:
return Response(
@@ -92,6 +113,7 @@ def site_webmanifest() -> Response:
headers=ASSET_CACHE_HEADERS,
)
@router.get("/theme.js")
def theme_js() -> Response:
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)
csrf_token = dashboard_csrf_token(management_slug, 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):
return _render_management_unlock_page(management_slug, account, csrf_token)
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(
management_slug: ManagementSlugPath,
request: Request,
@@ -68,7 +78,7 @@ async def activate_dashboard(
ipaddr: str | None = None,
myipv6: str | None = None,
ip6addr: str | None = None,
) -> RedirectResponse:
) -> RedirectResponse | HTMLResponse:
account = await get_active_management_account_async(store, management_slug)
form = await request_form(request, settings)
require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf"))
@@ -102,8 +112,8 @@ async def activate_dashboard(
),
status_code=500,
)
account = await run_in_threadpool(store.activate_account, hostname)
if not account:
activated_account = await run_in_threadpool(lambda: store.activate_account(hostname))
if not activated_account:
return _not_found()
await run_in_threadpool(
store.log_update_event,
@@ -117,14 +127,14 @@ async def activate_dashboard(
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(
management_slug: ManagementSlugPath,
request: Request,
settings: SettingsDep,
store: StoreDep,
service: ServiceDep,
) -> str:
) -> str | HTMLResponse:
account = await get_active_management_account_async(store, management_slug)
if not management_account_is_accepted(account):
return HTMLResponse(
@@ -172,10 +182,15 @@ async def rotate_dashboard_secrets(
@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)
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)
require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf"))
hostname = str(account["hostname"])
@@ -195,8 +210,7 @@ async def delete_dashboard_hostname(management_slug: ManagementSlugPath, request
return HTMLResponse(
_page(
"Hostname deleted",
trusted(
"""
trusted("""
<main>
<section class="hero-band compact">
<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>
</section>
</main>
"""
),
"""),
)
)
+14 -4
View File
@@ -24,7 +24,9 @@ router = APIRouter()
@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)
if not account.get("dashboard_password_hash"):
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)
if not is_valid:
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,
)
response = RedirectResponse(f"/m/{management_slug}", status_code=303)
@@ -59,7 +63,10 @@ async def set_dashboard_password(
) -> Response:
account = await get_active_management_account_async(store, management_slug)
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)
require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf"))
hostname = str(account["hostname"])
@@ -129,7 +136,10 @@ async def remove_dashboard_password(
) -> Response:
account = await get_active_management_account_async(store, management_slug)
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)
require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf"))
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"))
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")
if not password_hash:
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:
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(
@@ -82,7 +87,9 @@ async def management_error_response_async(
message: str,
request_ip: str | None = None,
) -> 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(
+3 -1
View File
@@ -152,7 +152,9 @@ async def _update_hostname(
raise HTTPException(status_code=404, detail="not found")
if not service.hostname_is_managed(str(account["hostname"])):
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(
+71 -9
View File
@@ -1,3 +1,5 @@
from collections.abc import Mapping, Sequence
from pydantic import BaseModel, Field
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.")
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.")
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):
@@ -134,9 +138,9 @@ class AdminEventsResponse(BaseModel):
total: int
def _create_response(service: DdnsService, account: dict[str, str]) -> HostnameCreateResponse:
def _create_response(service: DdnsService, account: Mapping[str, object]) -> HostnameCreateResponse:
return HostnameCreateResponse(
hostname=account["hostname"],
hostname=str(account["hostname"]),
status="active" if account.get("accepted_at") else "pending",
update_url=service.fritz_update_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(
service: DdnsService,
account: dict[str, str | int | None],
history: list[dict[str, str | int | None]] | None = None,
account: Mapping[str, object],
history: Sequence[Mapping[str, object]] | None = None,
) -> HostnameStatusResponse:
account_for_url = {"update_slug": str(account["update_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(
hostname=str(account["hostname"]),
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(
hostname=str(row["hostname"]),
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"]),
detail=str(row["detail"]),
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(
hostname=str(account["hostname"]),
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)
average_latency = metrics.get("update_latency_ms_total", 0) / samples if samples else 0
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 secrets
import time
from collections.abc import Mapping
from datetime import UTC, datetime
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
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"])
password_hash = str(account.get("dashboard_password_hash") or "")
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()
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)):
raise HTTPException(status_code=403, detail="invalid csrf token")
+23 -11
View File
@@ -8,6 +8,7 @@ import sqlite3
import threading
import time
import urllib.parse
from collections.abc import Mapping
from dataclasses import dataclass
from fastapi import HTTPException, Request
@@ -18,6 +19,7 @@ from ..models import UpdateResult
from ..security import client_ip
from ..settings import DdnsSettings
from ..store import DdnsStore
from ..store.types import AccountRow
LOGGER = logging.getLogger(__name__)
INITIAL_PLACEHOLDER_IPV4 = "192.0.2.1"
@@ -41,7 +43,7 @@ class DdnsService:
self.store = store
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:
raise HTTPException(status_code=500, detail="DDNS_HOSTNAME_SUFFIX is required")
for _ in range(5):
@@ -79,6 +81,8 @@ class DdnsService:
source_ip = client_ip(request, self.settings)
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))
ipv4: str | None
ipv6: str | None
if not supplied_ip and not source_fallback:
ipv4 = INITIAL_PLACEHOLDER_IPV4
ipv6 = None
@@ -108,7 +112,9 @@ class DdnsService:
planned.update_ipv6,
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
except DnsProviderError as exc:
LOGGER.warning(
@@ -118,7 +124,9 @@ class DdnsService:
planned.update_ipv6,
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
result = self.store.commit_update_result(planned)
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)
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:
try:
publish_dns(planned, self.settings)
@@ -180,7 +190,9 @@ class DdnsService:
planned.update_ipv6,
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
result = self.store.commit_update_result(planned)
is_success = True
@@ -203,19 +215,19 @@ class DdnsService:
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("/")
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>"
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("/")
slug = urllib.parse.quote(account["management_slug"])
slug = urllib.parse.quote(str(account["management_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("/")
slug = urllib.parse.quote(account["management_slug"])
slug = urllib.parse.quote(str(account["management_slug"]))
return f"{base}/api/v1/router/register/{slug}"
def random_managed_hostname(self) -> str:
+2 -1
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import threading
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
from sqlite3 import Connection
@@ -31,7 +32,7 @@ class DdnsStore(AccountsMixin, MetricsMixin, RecordsEventsMixin, RateLimitCleanu
self._init_db()
@contextmanager
def connect(self):
def connect(self) -> Iterator[Connection]:
conn = self._thread_connection()
try:
yield conn
+7 -6
View File
@@ -3,10 +3,11 @@ from __future__ import annotations
from datetime import UTC, datetime
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:
now = datetime.now(UTC).isoformat()
update_slug = new_bearer_secret()
@@ -41,7 +42,7 @@ class AccountsMixin:
""",
(limit, offset),
).fetchall()
return [dict(row) for row in rows]
return [account_row(row) for row in rows]
def count_accounts(self) -> int:
with self.connect() as conn:
@@ -60,7 +61,7 @@ class AccountsMixin:
""",
(hostname,),
).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:
with self.connect() as conn:
@@ -71,7 +72,7 @@ class AccountsMixin:
""",
(update_slug,),
).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:
with self.connect() as conn:
@@ -85,7 +86,7 @@ class AccountsMixin:
""",
(hash_lookup_token(management_slug),),
).fetchone()
return dict(account) if account else None
return account_row(account) if account else None
def activate_account(self, hostname: str) -> AccountRow | None:
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 .base import StoreConnectionMixin
class MetricsMixin:
class MetricsMixin(StoreConnectionMixin):
def increment_metric(self, name: str, amount: float = 1.0) -> None:
now = datetime.now(UTC).isoformat()
with self.connect() as conn:
+12 -3
View File
@@ -1,14 +1,23 @@
from __future__ import annotations
import threading
import time
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
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]:
with self.connect() as conn:
rows = conn.execute(
@@ -20,7 +29,7 @@ class RateLimitCleanupMixin:
""",
(limit, offset),
).fetchall()
return [CleanupRunRow(**dict(row)) for row in rows]
return [cleanup_run_row(row) for row in rows]
def count_cleanup_runs(self) -> int:
with self.connect() as conn:
+10 -9
View File
@@ -4,10 +4,11 @@ import sqlite3
from datetime import UTC, datetime
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(
self,
hostname: str,
@@ -84,10 +85,10 @@ class RecordsEventsMixin:
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)
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:
rows = conn.execute(
"""
@@ -98,7 +99,7 @@ class RecordsEventsMixin:
""",
(limit, offset),
).fetchall()
return [dict(row) for row in rows]
return [record_row(row) for row in rows]
def count_records(self) -> int:
with self.connect() as conn:
@@ -167,7 +168,7 @@ class RecordsEventsMixin:
""",
(limit, offset),
).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]:
where_clause = "WHERE created_at < ?" if before else ""
@@ -189,7 +190,7 @@ class RecordsEventsMixin:
""",
params,
).fetchall()
return [dict(row) for row in rows]
return [update_event_row(row) for row in rows]
def count_update_events(self) -> int:
with self.connect() as conn:
@@ -228,7 +229,7 @@ def _merge_event_rows(
limit: int,
offset: int,
) -> list[UpdateEventRow]:
rows = [dict(row) for row in event_rows]
rows.extend(dict(row) for row in rollup_rows)
rows = [update_event_row(row) for row in event_rows]
rows.extend(update_event_row(row) for row in rollup_rows)
rows.sort(key=lambda row: str(row["created_at"]), reverse=True)
return rows[offset : offset + limit]
+6 -7
View File
@@ -2,7 +2,8 @@ from __future__ import annotations
from datetime import UTC, datetime, timedelta
from .types import ScheduledTaskRow
from .base import StoreConnectionMixin
from .types import ScheduledTaskRow, scheduled_task_row
def _now() -> datetime:
@@ -13,7 +14,7 @@ def _iso(value: datetime) -> str:
return value.isoformat()
class SchedulerMixin:
class SchedulerMixin(StoreConnectionMixin):
def ensure_scheduled_task(self, name: str, interval_seconds: int) -> None:
now = _now()
with self.connect() as conn:
@@ -86,12 +87,10 @@ class SchedulerMixin:
def list_scheduled_tasks(self) -> list[ScheduledTaskRow]:
with self.connect() as conn:
rows = conn.execute(
"""
rows = conn.execute("""
SELECT name, interval_seconds, next_run_at, lease_owner, lease_expires_at,
last_started_at, last_finished_at, last_status, last_error, updated_at
FROM scheduled_tasks
ORDER BY name
"""
).fetchall()
return [ScheduledTaskRow(**dict(row)) for row in rows]
""").fetchall()
return [scheduled_task_row(row) for row in rows]
+32 -56
View File
@@ -35,18 +35,15 @@ def initialize_schema(conn: sqlite3.Connection) -> None:
configure_connection(conn)
schema_version = conn.execute("PRAGMA user_version").fetchone()[0]
conn.execute("PRAGMA journal_mode = WAL")
conn.execute(
"""
conn.execute("""
CREATE TABLE IF NOT EXISTS records (
hostname TEXT PRIMARY KEY,
ipv4 TEXT,
ipv6 TEXT,
updated_at TEXT NOT NULL
)
"""
)
conn.execute(
"""
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS update_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
hostname TEXT NOT NULL,
@@ -57,12 +54,12 @@ def initialize_schema(conn: sqlite3.Connection) -> None:
source_ip 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_hostname_created_at ON update_events(hostname, created_at DESC)")
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 (
id INTEGER PRIMARY KEY AUTOINCREMENT,
hostname TEXT NOT NULL,
@@ -76,28 +73,24 @@ def initialize_schema(conn: sqlite3.Connection) -> None:
count INTEGER NOT NULL,
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(
"""
"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
ON update_event_rollups(hostname, last_seen_at DESC)
"""
)
conn.execute(
"""
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS cleanup_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TEXT NOT NULL,
unused_accounts_deleted INTEGER NOT NULL
)
"""
)
""")
_migrate_cleanup_runs_schema(conn)
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 (
name TEXT PRIMARY KEY,
interval_seconds INTEGER NOT NULL,
@@ -110,37 +103,29 @@ def initialize_schema(conn: sqlite3.Connection) -> None:
last_error TEXT NOT NULL DEFAULT '',
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(
"""
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_scheduled_tasks_due
ON scheduled_tasks(next_run_at, lease_expires_at)
"""
)
conn.execute(
"""
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS rate_limits (
key TEXT NOT NULL,
window INTEGER NOT NULL,
count INTEGER NOT NULL,
PRIMARY KEY(key, 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 (
name TEXT PRIMARY KEY,
value REAL NOT NULL,
updated_at TEXT NOT NULL
)
"""
)
conn.execute(
"""
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS accounts (
hostname TEXT PRIMARY KEY,
update_slug TEXT NOT NULL UNIQUE,
@@ -150,8 +135,7 @@ def initialize_schema(conn: sqlite3.Connection) -> None:
accepted_at TEXT,
disabled INTEGER NOT NULL DEFAULT 0
)
"""
)
""")
_ensure_column(conn, "accounts", "update_slug", "TEXT")
_ensure_column(conn, "accounts", "management_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_created_at")
conn.execute("DROP INDEX IF EXISTS idx_accounts_hostname_username")
conn.execute(
"""
conn.execute("""
CREATE TABLE accounts_new (
hostname TEXT PRIMARY KEY,
update_slug TEXT NOT NULL UNIQUE,
@@ -201,15 +184,12 @@ def _migrate_accounts_schema(conn: sqlite3.Connection) -> None:
accepted_at TEXT,
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)
SELECT hostname, update_slug, management_hash, dashboard_password_hash, created_at, accepted_at, disabled
FROM accounts
"""
)
""")
conn.execute("DROP TABLE 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"}:
return
conn.execute("DROP INDEX IF EXISTS idx_cleanup_runs_created_at")
conn.execute(
"""
conn.execute("""
CREATE TABLE cleanup_runs_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TEXT NOT NULL,
unused_accounts_deleted INTEGER NOT NULL
)
"""
)
conn.execute(
"""
""")
conn.execute("""
INSERT INTO cleanup_runs_new(id, created_at, unused_accounts_deleted)
SELECT id, created_at, unused_accounts_deleted
FROM cleanup_runs
"""
)
""")
conn.execute("DROP TABLE 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 typing import TypedDict
from typing import Any, TypedDict, cast
class AccountRow(TypedDict, total=False):
@@ -43,3 +43,30 @@ class ScheduledTaskRow(TypedDict, total=False):
last_status: str
last_error: 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 ""
)
row_class = "copy-field copy-field-secret" if is_secret else "copy-field copy-field-public"
return trusted(
f"""
return trusted(f"""
<div class="{row_class}">
<div class="copy-pill">
<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")}
</button>
</div>
"""
)
""")
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'{_svg_icon("clipboard")}<span data-copy-label>{escape(item_label)}</span>{_svg_icon("check", "check-icon")}</button>'
)
return trusted(
f"""
return trusted(f"""
<details class="copy-menu">
<summary aria-label="{safe_label}" title="{safe_label}">
{_svg_icon("more", "more-icon")}
@@ -99,22 +96,21 @@ def _copy_menu(label: str, items: list[tuple[str, str]]) -> SafeHtml:
{''.join(buttons)}
</div>
</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 ""
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)
return trusted(
f"""
return trusted(f"""
<div class="terminal-card code-window{safe_class}">
{_copy_icon_button(copy_label, copy_value)}
{code_lines}
</div>
"""
)
""")
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:
return trusted(
f"""
return trusted(f"""
<div class="card card-body gap-3 mt-4">
<div>
<h3>Manual updates</h3>
@@ -176,8 +171,7 @@ def _manual_update_panel(commands: str) -> SafeHtml:
</div>
{_code_window(commands, copy_label="manual update commands", class_name="manual-code")}
</div>
"""
)
""")
def _fritzbox_fields(
@@ -196,25 +190,23 @@ def _fritzbox_fields(
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"
return trusted(
f"""
secret_items = (
"Update URL, register URL, and dashboard link" if include_registration else "Update URL and dashboard link"
)
return trusted(f"""
<div class="alert alert-info my-3 py-2 small" role="note">
<strong>Public:</strong> Hostname and CNAME target can be shared.
<strong>Secret:</strong> {escape(secret_items)} can update or manage this hostname.
Secrets are hidden on screen. Copy still copies the real value.
</div>
"""
)
""")
def _message_band(message: str | None) -> SafeHtml:
if not message:
return trusted("")
return trusted(
f"""
return trusted(f"""
<div class="alert alert-warning mb-0 rounded-0 border-start-0 border-end-0" role="status">
<div class="container">{escape(message)}</div>
</div>
"""
)
""")
+3 -1
View File
@@ -17,7 +17,9 @@ def parse_form(body: bytes) -> dict[str, list[str]]:
try:
return urllib.parse.parse_qs(decoded, keep_blank_values=True, max_num_fields=MAX_FORM_FIELDS)
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]]:
+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")}
</button>
"""
return trusted(
f"""
return trusted(f"""
<a class="skip-link" href="#main">Skip to content</a>
<nav class="navbar navbar-expand-sm site-nav" aria-label="Main navigation">
<div class="container">
@@ -31,13 +30,11 @@ def _top_nav(label: str | None = None, links: SafeHtml | None = None) -> SafeHtm
</div>
</div>
</nav>
"""
)
""")
def _site_footer() -> SafeHtml:
return trusted(
f"""
return trusted(f"""
<footer class="site-footer border-top">
<div class="container footer-inner">
<div class="footer-made">Made with <span aria-label="love">❤️</span> by karl</div>
@@ -65,8 +62,7 @@ def _site_footer() -> SafeHtml:
</nav>
</div>
</footer>
"""
)
""")
def _page(
@@ -85,7 +81,11 @@ def _page(
raise TypeError("body must be a SafeHtml fragment built by a template helper")
extra_styles_html = extra_styles 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("")
social_url = canonical_url or ""
social_image = image_url or ""
@@ -93,14 +93,18 @@ def _page(
if social_url:
social_card = "summary_large_image" if social_image else "summary"
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:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="{attr(social_image_alt)}">
<meta name="twitter:image" content="{attr(social_image)}">
<meta name="twitter:image:alt" content="{attr(social_image_alt)}">
""" if social_image else ""
"""
if social_image
else ""
)
social_html = trusted(f"""
<meta property="og:type" content="website">
<meta property="og:title" content="{attr(title)}">
-1
View File
@@ -47,7 +47,6 @@ class RequestBodyLimitMiddleware:
await response(scope, receive, send)
def install_security_middleware(app: FastAPI, settings: DdnsSettings, store: DdnsStore) -> None:
@app.middleware("http")
async def security_middleware(request: Request, call_next):
+35 -22
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from datetime import datetime
from urllib.parse import urlencode
@@ -30,13 +31,14 @@ def _admin_secret_panel(title: str, rows: list[SafeHtml]) -> SafeHtml:
</section>
""")
def _render_admin_page(
service: DdnsService,
settings: DdnsSettings,
accounts: list[dict[str, str | int | None]],
cleanup_runs: list[dict[str, str | int]],
scheduled_tasks: list[dict[str, str | int | None]],
update_events: list[dict[str, str | int | None]],
accounts: Sequence[Mapping[str, object]],
cleanup_runs: Sequence[Mapping[str, object]],
scheduled_tasks: Sequence[Mapping[str, object]],
update_events: Sequence[Mapping[str, object]],
service_metrics: dict[str, float],
table_counts: 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)))
event_count = escape(table_counts.get("update_events", len(update_events)))
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))
event_pager = _server_page_links(admin_path, "update_events_offset", table_offsets.get("update_events", 0), table_limit, table_counts.get("update_events", 0))
account_pager = _server_page_links(
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)
totals_html = _totals_strip(table_counts, service_metrics)
return _page(
@@ -142,10 +152,7 @@ def _totals_strip(table_counts: dict[str, int], metrics: dict[str, float]) -> Sa
]
return trusted(
'<div class="admin-total-strip" aria-label="Service totals">'
+ "".join(
f'<span><strong>{escape(value)}</strong>{escape(label)}</span>'
for label, value in items
)
+ "".join(f"<span><strong>{escape(value)}</strong>{escape(label)}</span>" for label, value in items)
+ "</div>"
)
@@ -181,12 +188,15 @@ def _server_page_links(admin_path: str, offset_name: str, offset: int, limit: in
def _cleanup_panel(
settings: DdnsSettings,
cleanup_runs: list[dict[str, str | int]],
scheduled_tasks: list[dict[str, str | int | None]],
cleanup_runs: Sequence[Mapping[str, object]],
scheduled_tasks: Sequence[Mapping[str, object]],
safe_admin_path: str,
csrf: str,
) -> 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)
next_run = task.get("next_run_at")
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))),
("Average update time", f"{average_latency:.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(
f"""
cards = "\n".join(f"""
<span><strong>{escape(value)}</strong>{escape(label)}</span>
"""
for label, value in items
)
""" for label, value in items)
return trusted(f"""
<div class="admin-metric-strip" aria-label="Service metrics">
{cards}
@@ -250,13 +260,16 @@ def _metrics_panel(metrics: dict[str, float]) -> SafeHtml:
""")
def _metric_int(value: float) -> str:
return str(int(value))
def _metric_int(value: object) -> str:
try:
return str(int(float(str(value))))
except ValueError:
return "0"
def _format_interval(seconds: object) -> str:
try:
value = int(seconds)
value = int(str(seconds))
except (TypeError, ValueError):
return str(seconds)
if value % 3600 == 0:
+3 -2
View File
@@ -1,12 +1,13 @@
from __future__ import annotations
from collections.abc import Mapping
from datetime import datetime
from .components import _event_label
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"])
hostname = escape(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 "-")
hostname_raw = str(event.get("hostname") or "-")
detail_raw = str(event.get("detail") or "-")
+21 -14
View File
@@ -1,5 +1,7 @@
from __future__ import annotations
from collections.abc import Mapping
from ..services.ddns import DdnsService
from ..settings import DdnsSettings
from .components import (
@@ -19,7 +21,7 @@ def _render_management_page(
service: DdnsService,
settings: DdnsSettings,
management_slug: str,
account: dict[str, str | int | None],
account: Mapping[str, object],
csrf_token: str,
message: str | None = None,
request_ip: str | None = None,
@@ -37,11 +39,19 @@ def _render_management_page(
fritzbox_fields = _fritzbox_fields(update_url, hostname)
cname_record = f"your-subdomain.example.com. CNAME {hostname}."
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>
"""
)
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(
"KarlDNS management",
trusted(f"""
@@ -143,11 +153,12 @@ def _render_management_page(
extra_styles=trusted('<link href="/admin.css?v=5" rel="stylesheet">'),
)
def _render_pending_management_page(
service: DdnsService,
settings: DdnsSettings,
management_slug: str,
account: dict[str, str | int | None],
account: Mapping[str, object],
csrf_token: str,
message: str | None = None,
request_ip: str | None = None,
@@ -224,13 +235,11 @@ def _render_pending_management_page(
def _management_message(message: str | None) -> SafeHtml:
if not message:
return trusted("")
return trusted(
f"""
return trusted(f"""
<div class="alert alert-info mb-0 rounded-0 border-start-0 border-end-0" role="status">
<div class="container">{escape(message)}</div>
</div>
"""
)
""")
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>
</div>
"""
return trusted(
f"""
return trusted(f"""
<section class="dashboard-section">
<div class="container">
<div class="dashboard-settings-grid">
@@ -285,13 +293,12 @@ def _dashboard_password_panel(management_slug: str, csrf_token: str, password_en
</div>
</div>
</section>
"""
)
""")
def _render_management_unlock_page(
management_slug: str,
account: dict[str, str | int | None],
account: Mapping[str, object],
csrf_token: str,
message: str | None = None,
) -> 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 "-")
raw_status = str(event.get("status") or "")
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:
base_url = settings.public_base_url.rstrip("/")
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("/")
example_ipv4, _ = _example_update_ips(request_ip)
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}'"
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"
+2 -4
View File
@@ -8,8 +8,7 @@ def _linux_installer_section(base_url: str) -> SafeHtml:
base_url = base_url.rstrip("/")
system_install = f"curl -fsSL {base_url}/install.sh | sudo sh"
system_uninstall = f"curl -fsSL {base_url}/uninstall.sh | sudo sh"
return trusted(
f"""
return trusted(f"""
<section class="section" id="linux-installer">
<div class="container narrow">
<div class="text-center mb-4">
@@ -31,5 +30,4 @@ def _linux_installer_section(base_url: str) -> SafeHtml:
</div>
</div>
</section>
"""
)
""")
+14
View File
@@ -33,7 +33,9 @@ Issues = "https://github.com/StasonJatham/karldns.de/issues"
[project.optional-dependencies]
dev = [
"black>=26.5.1",
"httpx>=0.28",
"mypy>=2.1.0",
"pytest>=8.2",
"ruff>=0.8",
]
@@ -55,3 +57,15 @@ target-version = "py311"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B"]
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")
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("UPDATE update_events SET created_at = ? WHERE hostname = ?", ("2026-01-03T00:00:00+00:00", "raw-new.example.net"))
conn.execute(
"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(
"UPDATE update_event_rollups SET last_seen_at = ? WHERE hostname = ?",
("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-new.example.net", "203.0.113.2", None, "updated", "ok", "198.51.100.2")
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("UPDATE update_events SET created_at = ? WHERE hostname = ?", ("2026-01-02T00:00:00+00:00", "raw-new.example.net"))
conn.execute(
"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"))
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 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(
"/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:
database_path = tmp_path / "ddns.sqlite3"
with sqlite3.connect(database_path) as conn:
conn.execute(
"""
conn.execute("""
CREATE TABLE cleanup_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
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,
unused_accounts_deleted INTEGER NOT NULL
)
"""
)
""")
conn.execute(
"""
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:
columns = {row[1] for row in conn.execute("PRAGMA table_info(cleanup_runs)")}
row = conn.execute(
"""
row = conn.execute("""
SELECT unused_accounts_deleted
FROM cleanup_runs
"""
).fetchone()
""").fetchone()
assert columns == {"id", "created_at", "unused_accounts_deleted"}
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]
mmap_size = conn.execute("PRAGMA mmap_size").fetchone()[0]
user_version = conn.execute("PRAGMA user_version").fetchone()[0]
index_names = {
row[1]
for row in conn.execute(
"""
index_names = {row[1] for row in conn.execute("""
SELECT type, name
FROM sqlite_master
WHERE type = 'index'
"""
)
}
""")}
assert busy_timeout == 10_000
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"
with sqlite3.connect(database_path) as conn:
conn.execute("PRAGMA user_version = 2")
conn.execute(
"""
conn.execute("""
CREATE TABLE accounts (
hostname TEXT PRIMARY KEY,
username TEXT NOT NULL,
@@ -268,8 +258,7 @@ def test_accounts_schema_migration_removes_legacy_router_credentials(tmp_path: P
accepted_at TEXT,
disabled INTEGER NOT NULL DEFAULT 0
)
"""
)
""")
conn.execute(
"""
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"
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")
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
config = home_dir / ".config" / "karldns" / "updater.env"
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
+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:
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:
+40 -13
View File
@@ -58,7 +58,9 @@ def test_legacy_custom_account_form_is_rejected(tmp_path: Path) -> None:
)
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 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)
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)})
assert activated.status_code == 200
assert "Hostname registered" in activated.text
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:
@@ -322,7 +328,10 @@ def test_management_dashboard_password_can_be_removed(tmp_path: Path) -> None:
unlock = client.post(
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
@@ -430,12 +439,18 @@ def test_management_dashboard_rotates_update_url(tmp_path: Path) -> None:
assert "data-secret-toggle" 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 "Update URL changed. Update your router now." 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
statuses = {event["status"] for event in app.state.store.list_update_events_for_hostname("home.ddns.example.net", 10)}
assert (
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_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)
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 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 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
assert (
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:
@@ -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 "http://127.0.0.1:8099/api/v1/router/update/&lt;secret&gt;" 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:
@@ -579,7 +603,10 @@ def test_application_pages_use_stricter_script_csp(tmp_path: Path) -> None:
assert "<style>" not in response.text
assert "script-src 'self' 'unsafe-inline'" 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"]
bootstrap_css = client.get("/bootstrap-subset.css")
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:height" content="630"' 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 "Made with" 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"
store = DdnsStore(database_path)
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)
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"
store = DdnsStore(database_path)
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)
response = client.get(_update_path(account), params={"myip": "203.0.113.9"})
Generated
+270
View File
@@ -1,6 +1,10 @@
version = 1
revision = 3
requires-python = ">=3.11"
resolution-markers = [
"python_full_version >= '3.15'",
"python_full_version < '3.15'",
]
[[package]]
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" },
]
[[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]]
name = "certifi"
version = "2026.5.20"
@@ -192,16 +273,20 @@ dependencies = [
[package.optional-dependencies]
dev = [
{ name = "black" },
{ name = "httpx" },
{ name = "mypy" },
{ name = "pytest" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "black", marker = "extra == 'dev'", specifier = ">=26.5.1" },
{ name = "dnspython", specifier = ">=2.6" },
{ name = "fastapi", specifier = ">=0.111" },
{ name = "httpx", marker = "extra == 'dev'", specifier = ">=0.28" },
{ name = "mypy", marker = "extra == 'dev'", specifier = ">=2.1.0" },
{ name = "pydantic", specifier = ">=2.7" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.2" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.8" },
@@ -209,6 +294,139 @@ requires-dist = [
]
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]]
name = "packaging"
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" },
]
[[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]]
name = "pluggy"
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" },
]
[[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]]
name = "pyyaml"
version = "6.0.3"