Compare commits

...
5 Commits
8 changed files with 334 additions and 90 deletions
+11 -4
View File
@@ -10,6 +10,7 @@ import asyncio
import logging
from paskia import authsession, db, globals
from paskia.db.structs import Config
from paskia.util import hostutil
logger = logging.getLogger(__name__)
@@ -30,15 +31,18 @@ def _log_reset_link(passphrase: str, message: str | None = None) -> str:
return reset_link
async def bootstrap_system() -> None:
async def bootstrap_system(config: Config | None = None) -> None:
"""
Bootstrap the entire system with default data.
Uses db.bootstrap() which performs all operations in a single transaction.
The transaction log will show a single "bootstrap" action with all changes.
Args:
config: Configuration to store (rp_id, rp_name, origins, etc.)
"""
# Call the single-transaction bootstrap function
reset_passphrase = db.bootstrap()
reset_passphrase = db.bootstrap(config=config)
# Log the reset link (this is separate from the transaction log)
_log_reset_link(reset_passphrase, "✅ Bootstrap completed!")
@@ -89,10 +93,13 @@ async def check_admin_credentials() -> bool:
return False
async def bootstrap_if_needed() -> bool:
async def bootstrap_if_needed(config: Config | None = None) -> bool:
"""
Check if system needs bootstrapping and perform it if necessary.
Args:
config: Configuration to store during bootstrap (rp_id, rp_name, origins, etc.)
Returns:
bool: True if bootstrapping was performed, False if system was already set up
"""
@@ -105,7 +112,7 @@ async def bootstrap_if_needed() -> bool:
# No admin permission found, need to bootstrap
# Bootstrap creates the admin user AND the reset link, so no need to check credentials after
await bootstrap_system()
await bootstrap_system(config=config)
return True
+4
View File
@@ -47,6 +47,7 @@ from paskia.db.operations import (
delete_session,
delete_sessions_for_user,
delete_user,
get_config,
get_organization_users,
get_reset_token,
get_user_credential_ids,
@@ -55,6 +56,7 @@ from paskia.db.operations import (
login,
remove_permission_from_org,
remove_permission_from_role,
set_config,
set_session_host,
update_credential_sign_count,
update_org_name,
@@ -110,6 +112,7 @@ __all__ = [
"build_session",
"build_user",
# Read ops
"get_config",
"get_organization_users",
"get_reset_token",
"get_user_credential_ids",
@@ -138,6 +141,7 @@ __all__ = [
"login",
"remove_permission_from_org",
"remove_permission_from_role",
"set_config",
"set_session_host",
"update_credential_sign_count",
"update_org_name",
+34 -23
View File
@@ -4,6 +4,8 @@ JSONL persistence layer for the database.
import copy
import logging
import os
import signal
from collections import deque
from contextlib import contextmanager
from datetime import UTC, datetime
@@ -69,22 +71,25 @@ def create_change_record(
# Actions that are allowed to create a new database file
_BOOTSTRAP_ACTIONS = frozenset({"bootstrap"})
# Flag to prevent duplicate error messages on fatal flush failure
_flush_failed = False
async def flush_changes(
db_path: Path,
pending_changes: deque[_ChangeRecord],
) -> bool:
) -> None:
"""Write all pending changes to disk.
Args:
db_path: Path to the JSONL database file
pending_changes: Queue of pending change records (will be cleared on success)
Returns:
True if flush succeeded, False otherwise
On failure, logs an error and sends SIGTERM to trigger graceful shutdown.
"""
if not pending_changes:
return True
global _flush_failed
if _flush_failed or not pending_changes:
return
if not db_path.exists():
first_action = pending_changes[0].a
@@ -94,26 +99,25 @@ async def flush_changes(
"only bootstrap can create a new database",
first_action,
)
pending_changes.clear()
return False
_flush_failed = True
os.kill(os.getpid(), signal.SIGTERM)
return
changes_to_write = list(pending_changes)
pending_changes.clear()
try:
lines = [_change_encoder.encode(change) for change in changes_to_write]
if not lines:
return True
pending_changes.clear()
return
async with aiofiles.open(db_path, "ab") as f:
await f.write(b"\n".join(lines) + b"\n")
return True
except OSError:
_logger.exception("Failed to flush database changes")
# Re-queue the changes on failure
for change in reversed(changes_to_write):
pending_changes.appendleft(change)
return False
pending_changes.clear()
except OSError as e:
_logger.error("Failed to flush database: %s", e)
_flush_failed = True
os.kill(os.getpid(), signal.SIGTERM)
class JsonlStore:
@@ -130,10 +134,13 @@ class JsonlStore:
self._transaction_snapshot: dict[str, Any] | None = None
self._current_version: int = DBVER # Schema version for new databases
async def load(self, db_path: str | None = None) -> None:
async def load(
self, db_path: str | None = None, *, rp_id: str = "localhost"
) -> None:
"""Load data from JSONL change log."""
if db_path is not None:
self.db_path = Path(db_path)
self._rp_id = rp_id
if not self.db_path.exists():
return
@@ -152,8 +159,10 @@ class JsonlStore:
self._current_version = change.get("v", 0)
except Exception as e:
raise ValueError(f"Error parsing line {line_num}: {e}")
except (OSError, ValueError, msgspec.DecodeError) as e:
raise ValueError(f"Failed to load database: {e}")
except OSError as e:
raise SystemExit(f"Failed to load database: {e}")
except (ValueError, msgspec.DecodeError) as e:
raise SystemExit(f"Failed to load database: {e}")
if not data_dict:
return
@@ -169,7 +178,9 @@ class JsonlStore:
self._queue_change(action, new_version, current)
# Apply schema migrations one at a time
await apply_all_migrations(data_dict, self._current_version, persist_migration)
await apply_all_migrations(
data_dict, self._current_version, persist_migration, rp_id=rp_id
)
# Decode to msgspec struct
decoder = msgspec.json.Decoder(DB)
@@ -209,7 +220,7 @@ class JsonlStore:
except (ValueError, KeyError):
user_display = user
log_change(action, diff, user_display, self._previous_builtins)
log_change(action, diff, user_display, self._previous_builtins, self.db)
self._previous_builtins = copy.deepcopy(current)
@contextmanager
@@ -277,6 +288,6 @@ class JsonlStore:
self._in_transaction = False
self._transaction_snapshot = None
async def flush(self) -> bool:
async def flush(self) -> None:
"""Write all pending changes to disk."""
return await flush_changes(self.db_path, self._pending_changes)
await flush_changes(self.db_path, self._pending_changes)
+244 -43
View File
@@ -3,15 +3,27 @@ Database change logging with pretty-printed diffs.
Provides a logger for JSONL database changes that formats diffs
in a human-readable path.notation style with color coding.
UUIDs are replaced with display names where available, or the last
section of the UUID hex for types without display names.
"""
import logging
import re
import sys
from typing import Any
from typing import TYPE_CHECKING, Any
from uuid import UUID
if TYPE_CHECKING:
from paskia.db.structs import DB
logger = logging.getLogger("paskia.db")
# UUID regex pattern (8-4-4-4-12 hex format)
_UUID_PATTERN = re.compile(
r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
)
# Pattern to match control characters and bidirectional overrides
_UNSAFE_CHARS = re.compile(
r"[\x00-\x1f\x7f-\x9f" # C0 and C1 control characters
@@ -32,13 +44,145 @@ _ACTION = "\033[1;34m" # Bold blue for action name
_USER = "\033[0;34m" # Blue for user display
def _is_uuid(value: str) -> bool:
"""Check if a string is a UUID."""
return bool(_UUID_PATTERN.match(value))
def _uuid_suffix(uuid_str: str) -> str:
"""Get the last section of a UUID (after the last hyphen)."""
return uuid_str.rsplit("-", 1)[-1]
class UuidResolver:
"""Resolve UUIDs to display names or short suffixes.
Uses the previous state for lookups to show the name before any changes.
"""
def __init__(self, db: "DB | None" = None, previous: dict | None = None):
self._db = db
self._previous = previous
def resolve(self, uuid_str: str) -> str:
"""Resolve a UUID to its display name or short suffix."""
display = self._get_display_name(uuid_str)
if display:
return display
return _uuid_suffix(uuid_str)
def _get_display_name(self, uuid_str: str) -> str | None:
"""Look up display name for a UUID.
First checks the previous state (to show names before changes),
then falls back to the current database.
"""
# Try previous state first (for showing name before a change)
name = self._lookup_in_previous(uuid_str)
if name:
return name
# Fall back to current database
return self._lookup_in_db(uuid_str)
def _lookup_in_previous(self, uuid_str: str) -> str | None:
"""Look up display name in the previous state dict."""
if not self._previous:
return None
# Check users
if "users" in self._previous and uuid_str in self._previous["users"]:
user_data = self._previous["users"][uuid_str]
if isinstance(user_data, dict) and "display_name" in user_data:
return user_data["display_name"]
# Check orgs
if "orgs" in self._previous and uuid_str in self._previous["orgs"]:
org_data = self._previous["orgs"][uuid_str]
if isinstance(org_data, dict) and "display_name" in org_data:
return org_data["display_name"]
# Check roles
if "roles" in self._previous and uuid_str in self._previous["roles"]:
role_data = self._previous["roles"][uuid_str]
if isinstance(role_data, dict) and "display_name" in role_data:
return role_data["display_name"]
# Check permissions
if (
"permissions" in self._previous
and uuid_str in self._previous["permissions"]
):
perm_data = self._previous["permissions"][uuid_str]
if isinstance(perm_data, dict) and "display_name" in perm_data:
return perm_data["display_name"]
# Check credentials - look up user name
if (
"credentials" in self._previous
and uuid_str in self._previous["credentials"]
):
cred_data = self._previous["credentials"][uuid_str]
if isinstance(cred_data, dict) and "user" in cred_data:
user_uuid = cred_data["user"]
if "users" in self._previous and user_uuid in self._previous["users"]:
user_data = self._previous["users"][user_uuid]
if isinstance(user_data, dict) and "display_name" in user_data:
return f"credential of {user_data['display_name']}"
return None
def _lookup_in_db(self, uuid_str: str) -> str | None:
"""Look up display name in the current database."""
if not self._db:
return None
try:
uuid_obj = UUID(uuid_str)
except ValueError:
return None
# Check users
if uuid_obj in self._db.users:
return self._db.users[uuid_obj].display_name
# Check orgs
if uuid_obj in self._db.orgs:
return self._db.orgs[uuid_obj].display_name
# Check roles
if uuid_obj in self._db.roles:
return self._db.roles[uuid_obj].display_name
# Check permissions
if uuid_obj in self._db.permissions:
return self._db.permissions[uuid_obj].display_name
# Check credentials - identify by user name
if uuid_obj in self._db.credentials:
cred = self._db.credentials[uuid_obj]
if cred.user_uuid in self._db.users:
user_name = self._db.users[cred.user_uuid].display_name
return f"credential of {user_name}"
return None
def _use_color() -> bool:
"""Check if we should use color output."""
return sys.stderr.isatty()
def _format_value(value: Any, use_color: bool, max_len: int = 60) -> str:
"""Format a value for display, truncating if needed."""
def _format_value(
value: Any,
use_color: bool,
max_len: int = 60,
resolver: UuidResolver | None = None,
) -> str:
"""Format a value for display, truncating if needed.
If resolver is provided, UUIDs are replaced with display names or short suffixes.
"""
if value is None:
return "null"
@@ -49,6 +193,9 @@ def _format_value(value: Any, use_color: bool, max_len: int = 60) -> str:
return str(value)
if isinstance(value, str):
# Check if it's a UUID and resolve to display name
if resolver and _is_uuid(value):
return resolver.resolve(value)
# Filter out control characters and bidirectional overrides
value = _UNSAFE_CHARS.sub("", value)
# Truncate long strings
@@ -59,18 +206,26 @@ def _format_value(value: Any, use_color: bool, max_len: int = 60) -> str:
if isinstance(value, dict):
if not value:
return "{}"
# For small dicts, show inline
if len(value) == 1:
k, v = next(iter(value.items()))
return "{" + f"{k}: {_format_value(v, use_color, max_len=30)}" + "}"
return f"{{...{len(value)} keys}}"
# Check if all values are True - render as set-like {key1, key2}
all_true = all(v is True for v in value.values())
parts = []
for k, v in value.items():
# Replace UUID keys with display names
key_display = resolver.resolve(k) if resolver and _is_uuid(k) else k
if all_true:
parts.append(key_display)
else:
val_display = _format_value(v, use_color, max_len=30, resolver=resolver)
parts.append(f"{key_display}: {val_display}")
return "{" + ", ".join(parts) + "}"
if isinstance(value, list):
if not value:
return "[]"
if len(value) == 1:
return "[" + _format_value(value[0], use_color, max_len=30) + "]"
return f"[...{len(value)} items]"
parts = [
_format_value(v, use_color, max_len=30, resolver=resolver) for v in value
]
return "[" + ", ".join(parts) + "]"
# Fallback for other types
text = str(value)
@@ -79,10 +234,20 @@ def _format_value(value: Any, use_color: bool, max_len: int = 60) -> str:
return text
def _format_path(path: list[str], use_color: bool) -> str:
"""Format a path as dot notation with prefix in dark grey, final in default."""
def _format_path(
path: list[str], use_color: bool, resolver: UuidResolver | None = None
) -> str:
"""Format a path as dot notation with prefix in dark grey, final in default.
If resolver is provided, UUIDs in the path are replaced with display names.
"""
if not path:
return ""
# Replace UUIDs in path with display names
if resolver:
path = [resolver.resolve(p) if _is_uuid(p) else p for p in path]
if not use_color:
return ".".join(path)
if len(path) == 1:
@@ -176,16 +341,32 @@ def _collect_changes(
def _format_change_lines(
change_type: str, path: list[str], value: Any, use_color: bool
change_type: str,
path: list[str],
value: Any,
use_color: bool,
resolver: UuidResolver | None = None,
) -> list[str]:
"""Format a single change as one or more lines."""
"""Format a single change as one or more lines.
If resolver is provided, UUIDs are replaced with display names.
"""
# Helper to format path with UUID replacement
def fmt_path(p: list[str]) -> list[str]:
if resolver:
return [resolver.resolve(x) if _is_uuid(x) else x for x in p]
return p
formatted_path = fmt_path(path)
if change_type == "delete":
if not use_color:
return [f" {'.'.join(path)}"]
if len(path) == 1:
return [f" {_DELETE}{path[0]}{_RESET}"]
prefix = ".".join(path[:-1])
final = path[-1]
return [f" {'.'.join(formatted_path)}"]
if len(formatted_path) == 1:
return [f" {_DELETE}{formatted_path[0]}{_RESET}"]
prefix = ".".join(formatted_path[:-1])
final = formatted_path[-1]
return [f" {_PATH_PREFIX}{prefix}.{_RESET}{_DELETE}{final}{_RESET}"]
if change_type == "add":
@@ -195,56 +376,66 @@ def _format_change_lines(
lines = []
# First line: path with green final element and grey =
if not use_color:
lines.append(f" {'.'.join(path)} =")
elif len(path) == 1:
lines.append(f" {_ADD}{path[0]}{_RESET} {_DIM}={_RESET}")
lines.append(f" {'.'.join(formatted_path)} =")
elif len(formatted_path) == 1:
lines.append(f" {_ADD}{formatted_path[0]}{_RESET} {_DIM}={_RESET}")
else:
prefix = ".".join(path[:-1])
final = path[-1]
prefix = ".".join(formatted_path[:-1])
final = formatted_path[-1]
lines.append(
f" {_PATH_PREFIX}{prefix}.{_RESET}{_ADD}{final}{_RESET} {_DIM}={_RESET}"
)
# Child lines: indented key: value, with aligned values
max_key_len = max(len(k) for k in value.keys())
field_width = max(max_key_len, 12) # minimum 12 chars
# Format keys (may contain UUIDs)
formatted_items = []
for k, v in value.items():
v_str = _format_value(v, use_color)
padding = " " * (field_width - len(k))
k_display = resolver.resolve(k) if resolver and _is_uuid(k) else k
v_str = _format_value(v, use_color, resolver=resolver)
formatted_items.append((k_display, v_str))
max_key_len = max(len(k) for k, _ in formatted_items)
field_width = max(max_key_len, 12) # minimum 12 chars
for k_display, v_str in formatted_items:
padding = " " * (field_width - len(k_display))
if use_color:
lines.append(f" {k}{_DIM}:{_RESET}{padding} {v_str}")
lines.append(f" {k_display}{_DIM}:{_RESET}{padding} {v_str}")
else:
lines.append(f" {k}:{padding} {v_str}")
lines.append(f" {k_display}:{padding} {v_str}")
return lines
else:
value_str = _format_value(value, use_color)
value_str = _format_value(value, use_color, resolver=resolver)
if not use_color:
return [f" {'.'.join(path)} = {value_str}"]
if len(path) == 1:
return [f" {_ADD}{path[0]}{_RESET} {_DIM}={_RESET} {value_str}"]
prefix = ".".join(path[:-1])
final = path[-1]
return [f" {'.'.join(formatted_path)} = {value_str}"]
if len(formatted_path) == 1:
return [
f" {_ADD}{formatted_path[0]}{_RESET} {_DIM}={_RESET} {value_str}"
]
prefix = ".".join(formatted_path[:-1])
final = formatted_path[-1]
return [
f" {_PATH_PREFIX}{prefix}.{_RESET}{_ADD}{final}{_RESET} {_DIM}={_RESET} {value_str}"
]
# update: Existing item being updated - normal path colors
value_str = _format_value(value, use_color)
path_str = _format_path(path, use_color)
value_str = _format_value(value, use_color, resolver=resolver)
path_str = _format_path(path, use_color, resolver=resolver)
if use_color:
return [f" {path_str} {_DIM}={_RESET} {value_str}"]
return [f" {path_str} = {value_str}"]
def format_diff(diff: dict, previous: dict | None = None) -> list[str]:
def format_diff(
diff: dict, previous: dict | None = None, db: "DB | None" = None
) -> list[str]:
"""
Format a JSON diff as human-readable lines.
Args:
diff: The JSON diff dict
previous: The previous state dict (for determining add vs update)
db: Optional database for looking up display names
Returns a list of formatted lines (without newlines).
Single changes return one line, multiple changes return multiple lines.
UUIDs are replaced with display names (using previous state for lookups).
"""
use_color = _use_color()
changes: list[tuple[str, list[str], Any]] = []
@@ -253,10 +444,15 @@ def format_diff(diff: dict, previous: dict | None = None) -> list[str]:
if not changes:
return []
# Create resolver for UUID replacement (uses previous state for lookups)
resolver = UuidResolver(db, previous)
# Format each change
lines = []
for change_type, path, value in changes:
lines.extend(_format_change_lines(change_type, path, value, use_color))
lines.extend(
_format_change_lines(change_type, path, value, use_color, resolver)
)
return lines
@@ -282,18 +478,23 @@ def log_change(
diff: dict,
user_display: str | None = None,
previous: dict | None = None,
db: "DB | None" = None,
) -> None:
"""
Log a database change with pretty-printed diff.
UUIDs are replaced with display names for readability. For types without
display names (e.g., credentials), the last section of the UUID is used.
Args:
action: The action name (e.g., "login", "admin:delete_user")
diff: The JSON diff dict
user_display: Optional display name of the user who performed the action
previous: The previous state dict (for determining add vs update)
db: Optional database for looking up display names
"""
header = format_action_header(action, user_display)
diff_lines = format_diff(diff, previous)
diff_lines = format_diff(diff, previous, db)
if not diff_lines:
logger.info(header)
+10 -2
View File
@@ -8,12 +8,18 @@ Each migration should be idempotent and only run when needed.
from collections.abc import Awaitable, Callable
def migrate_v1(d: dict) -> None:
def migrate_v1(d: dict, **kwargs) -> None:
"""Remove Org.created_at fields."""
for org_data in d["orgs"].values():
org_data.pop("created_at", None)
def migrate_v2(d: dict, *, rp_id: str = "localhost") -> None:
"""Add config field if missing."""
if "config" not in d:
d["config"] = {"rp_id": rp_id}
migrations = sorted(
[f for n, f in globals().items() if n.startswith("migrate_v")],
key=lambda f: int(f.__name__.removeprefix("migrate_v")),
@@ -26,8 +32,10 @@ async def apply_all_migrations(
data_dict: dict,
current_version: int,
persist: Callable[[str, int, dict], Awaitable[None]],
*,
rp_id: str = "localhost",
) -> None:
while current_version < DBVER:
migrations[current_version](data_dict)
migrations[current_version](data_dict, rp_id=rp_id)
current_version += 1
await persist(f"migrate:v{current_version}", current_version, data_dict)
+10 -2
View File
@@ -21,6 +21,7 @@ from paskia.db.jsonl import (
)
from paskia.db.structs import (
DB,
Config,
Credential,
Org,
Permission,
@@ -49,7 +50,7 @@ async def init(rp_id: str = "localhost", *args, **kwargs):
return
default_path = f"{rp_id}.paskiadb"
db_path = os.environ.get("PASKIA_DB", default_path)
await _store.load(db_path)
await _store.load(db_path, rp_id=rp_id)
_db = _store.db
_initialized = True
@@ -722,6 +723,7 @@ def bootstrap(
admin_name: str = "Admin",
reset_passphrase: str | None = None,
reset_expiry: datetime | None = None,
config: Config | None = None,
) -> str:
"""Bootstrap the entire system in a single transaction.
@@ -731,6 +733,7 @@ def bootstrap(
- Organization with Administration role
- Admin user with Administration role
- Reset token for admin registration
- Config (if provided)
This is the only way to create a new database file.
All data is created atomically - if any step fails, nothing is written.
@@ -740,6 +743,7 @@ def bootstrap(
admin_name: Display name for the admin user (default: "Admin")
reset_passphrase: Passphrase for the reset token (generated if not provided)
reset_expiry: Expiry datetime for the reset token (default: 14 days)
config: Configuration to store (rp_id, rp_name, origins, etc.)
Returns:
The reset passphrase for admin registration.
@@ -820,6 +824,10 @@ def bootstrap(
)
_db.reset_tokens[reset_token.key] = reset_token
# Set config if provided
if config is not None:
_db.config = config
return reset_passphrase
@@ -835,5 +843,5 @@ def get_config() -> Config:
async def set_config(config: Config) -> None:
"""Update the stored configuration."""
async with _db.transaction("update_config"):
with _db.transaction("update_config"):
_db.config = config
+4 -3
View File
@@ -7,6 +7,7 @@ from uuid import UUID
import msgspec
import uuid7
from msgspec import field
from paskia import db
from paskia.util.hostutil import normalize_host
@@ -397,10 +398,10 @@ class SessionContext(msgspec.Struct):
permissions: list[Permission] = []
class Config(msgspec.Struct, dict=True, omit_defaults=True):
class Config(msgspec.Struct, frozen=True, dict=True, omit_defaults=True):
"""Stored configuration for the instance."""
rp_id: str | None = None
rp_id: str
rp_name: str | None = None
origins: list[str] | None = None
auth_host: str | None = None
@@ -422,7 +423,7 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False):
credentials: dict[UUID, Credential] = {}
sessions: dict[str, Session] = {}
reset_tokens: dict[bytes, ResetToken] = {}
config: Config = Config()
config: Config = field(default_factory=lambda: Config(rp_id="localhost"))
def __post_init__(self):
# Store reference for persistence (not serialized)
+17 -13
View File
@@ -6,7 +6,8 @@ import os
from urllib.parse import urlparse
from fastapi_vue.hostutil import parse_endpoint
from uvicorn import Config, Server
from uvicorn import Config as UvicornConfig
from uvicorn import Server
from uvicorn import run as uvicorn_run
from paskia import globals as _globals
@@ -198,15 +199,14 @@ def main():
startupbox.print_startup_config(config)
if args.save:
new_config = Config(
rp_id=args.rp_id,
rp_name=args.rp_name,
origins=args.origins,
auth_host=args.auth_host,
listen=args.listen,
)
asyncio.run(set_config(new_config))
# Build config to save (for bootstrap or explicit --save)
cli_config = Config(
rp_id=args.rp_id,
rp_name=args.rp_name,
origins=args.origins,
auth_host=args.auth_host,
listen=args.listen,
)
run_kwargs: dict = {
"log_level": "warning", # Suppress startup messages; we use custom logging
@@ -228,7 +228,11 @@ def main():
origins=config.origins,
bootstrap=False,
)
await bootstrap_if_needed()
# Pass config to bootstrap - it will be saved within the bootstrap transaction
await bootstrap_if_needed(config=cli_config)
# Also save config if --save was explicitly used (even without bootstrap)
if args.save:
await set_config(cli_config)
await flush()
if len(endpoints) > 1:
@@ -236,7 +240,7 @@ def main():
for ep in endpoints:
tg.create_task(
Server(
Config(app="paskia.fastapi:app", **run_kwargs, **ep)
UvicornConfig(app="paskia.fastapi:app", **run_kwargs, **ep)
).serve()
)
elif DEVMODE:
@@ -245,7 +249,7 @@ def main():
uvicorn_run("paskia.fastapi:app", **run_kwargs, **ep)
else:
server = Server(
Config(app="paskia.fastapi:app", **run_kwargs, **endpoints[0])
UvicornConfig(app="paskia.fastapi:app", **run_kwargs, **endpoints[0])
)
await server.serve()