Release v0.1.2

This commit is contained in:
2026-06-25 19:58:20 +02:00
parent 15794e920e
commit 02564047e9
73 changed files with 4073 additions and 478 deletions

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
import argparse
import importlib.util
from dataclasses import dataclass
import inspect
import os
import sys
@@ -19,6 +20,17 @@ DEFAULT_APP = "govoplan_core.server.app:app"
DEFAULT_CONFIG = "govoplan_core.server.default_config:get_server_config"
@dataclass(slots=True)
class DevserverState:
config_path: str | None
runtime_root: Path | None
database_url: str
bootstrap_db_path: Path | None
config: GovoplanServerConfig
registry: PlatformRegistry
reload_dirs: list[str]
def _config_module_runtime_root(config_path: str | None) -> Path | None:
object_path = config_path or os.getenv("GOVOPLAN_SERVER_CONFIG")
if not object_path:
@@ -42,12 +54,13 @@ def _config_module_runtime_root(config_path: str | None) -> Path | None:
def apply_runtime_defaults(config_path: str | None) -> Path | None:
runtime_root = _config_module_runtime_root(config_path)
if runtime_root is None:
return None
runtime_root = _config_module_runtime_root(config_path) or Path.cwd().resolve()
(runtime_root / "runtime").mkdir(parents=True, exist_ok=True)
(runtime_root / "runtime" / "files").mkdir(parents=True, exist_ok=True)
(runtime_root / "runtime" / "mock-mailbox").mkdir(parents=True, exist_ok=True)
defaults = {
"DATABASE_URL": f"sqlite:///{runtime_root / 'multimailer-dev.db'}",
"DATABASE_URL": f"sqlite:///{runtime_root / 'runtime' / 'multimailer-dev.db'}",
"FILE_STORAGE_LOCAL_ROOT": str(runtime_root / "runtime" / "files"),
"MOCK_MAILBOX_DIR": str(runtime_root / "runtime" / "mock-mailbox"),
}
@@ -56,6 +69,55 @@ def apply_runtime_defaults(config_path: str | None) -> Path | None:
return runtime_root
def _sqlite_database_path(database_url: str) -> Path | None:
if not database_url.startswith("sqlite:///"):
return None
raw_path = database_url[len("sqlite:///"):]
if not raw_path or raw_path == ":memory:":
return None
path = Path(raw_path).expanduser()
return path if path.is_absolute() else Path.cwd() / path
def validate_sqlite_database_url(database_url: str) -> None:
db_path = _sqlite_database_path(database_url)
if db_path is None:
return
if db_path.name.startswith(".fuse"):
raise SystemExit(
"DATABASE_URL points at a FUSE-hidden SQLite file. "
"Stop the process still holding the deleted database, unset DATABASE_URL, "
"or set DATABASE_URL to a normal .db path under the core runtime directory."
)
if not db_path.parent.exists():
raise SystemExit(
f"DATABASE_URL points to {db_path}, but its parent directory does not exist. "
"Unset stale DATABASE_URL or set it to "
f"sqlite:///{Path.cwd().resolve() / 'runtime' / 'multimailer-dev.db'}."
)
def _env_truthy(value: str | None) -> bool:
return value is not None and value.strip().lower() in {"1", "true", "yes", "on"}
def enable_dev_bootstrap_for_missing_sqlite(database_url: str) -> Path | None:
db_path = _sqlite_database_path(database_url)
if db_path is None or db_path.name.startswith(".fuse") or not db_path.parent.exists():
return None
if db_path.exists() and db_path.stat().st_size > 0:
return None
os.environ.setdefault("DEV_BOOTSTRAP_ENABLED", "true")
return db_path
def apply_detected_dev_bootstrap(config: GovoplanServerConfig, bootstrap_db_path: Path | None) -> None:
if bootstrap_db_path is None or not _env_truthy(os.getenv("DEV_BOOTSTRAP_ENABLED")):
return
if config.settings is not None and hasattr(config.settings, "dev_bootstrap_enabled"):
setattr(config.settings, "dev_bootstrap_enabled", True)
def _source_root_for_path(path: str | os.PathLike[str]) -> Path | None:
source_path = Path(path).resolve()
directory = source_path if source_path.is_dir() else source_path.parent
@@ -177,31 +239,86 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
parser.add_argument("--port", type=int, default=8000, help="Port to bind. Default: 8000.")
parser.add_argument("--no-reload", action="store_true", help="Disable uvicorn reload.")
parser.add_argument("--reload-dir", action="append", default=[], help="Additional directory to watch. May be passed multiple times.")
parser.add_argument("--smoke", action="store_true", help="Prepare runtime paths, run app startup, print effective paths, and exit without uvicorn.")
return parser.parse_args(argv)
def prepare_devserver(config_path: str | None, *, extra_reload_dirs: Sequence[str] = ()) -> DevserverState:
runtime_root = apply_runtime_defaults(config_path)
database_url = os.getenv("DATABASE_URL", "")
validate_sqlite_database_url(database_url)
bootstrap_db_path = enable_dev_bootstrap_for_missing_sqlite(database_url)
config = load_server_config(config_path)
database_url = str(getattr(config.settings, "database_url", database_url) or "")
validate_sqlite_database_url(database_url)
if bootstrap_db_path is None:
bootstrap_db_path = enable_dev_bootstrap_for_missing_sqlite(database_url)
apply_detected_dev_bootstrap(config, bootstrap_db_path)
registry = build_platform_registry(config.enabled_modules, manifest_factories=config.manifest_factories)
reload_dirs = build_reload_dirs(config, config_path=config_path, registry=registry, extra_dirs=extra_reload_dirs)
return DevserverState(
config_path=config_path,
runtime_root=runtime_root,
database_url=database_url,
bootstrap_db_path=bootstrap_db_path,
config=config,
registry=registry,
reload_dirs=reload_dirs,
)
def print_devserver_summary(state: DevserverState, *, app: str, no_reload: bool) -> None:
print(f"Starting GovOPlaN dev server: {app}")
print(f"Config: {state.config_path or DEFAULT_CONFIG}")
print(f"Runtime root: {state.runtime_root}")
if state.database_url:
print(f"Database: {state.database_url}")
if state.bootstrap_db_path is not None:
bootstrap_state = "enabled" if getattr(state.config.settings, "dev_bootstrap_enabled", False) else "disabled by DEV_BOOTSTRAP_ENABLED"
print(f"Dev bootstrap for missing SQLite DB: {bootstrap_state} ({state.bootstrap_db_path})")
print("Modules: " + ", ".join(manifest.id for manifest in state.registry.manifests()))
if no_reload:
print("Reload: disabled")
else:
print("Reload dirs:")
for directory in state.reload_dirs:
print(f" - {directory}")
def run_smoke_check(state: DevserverState) -> None:
try:
from fastapi.testclient import TestClient
except ModuleNotFoundError as exc:
raise SystemExit("fastapi.testclient is not available. Install govoplan-core dev/server requirements before running --smoke.") from exc
from govoplan_core.server.app import create_app
print("Smoke check: creating ASGI app and running startup")
app = create_app(state.config)
with TestClient(app) as client:
response = client.get("/health")
if response.status_code != 200:
raise SystemExit(f"Smoke check failed: /health returned {response.status_code}: {response.text}")
db_path = _sqlite_database_path(state.database_url)
if state.bootstrap_db_path is not None:
if db_path is None or not db_path.exists() or db_path.stat().st_size <= 0:
raise SystemExit(f"Smoke check failed: expected initialized SQLite DB at {state.bootstrap_db_path}")
print("Smoke check: OK")
def main(argv: Sequence[str] | None = None) -> int:
args = parse_args(argv)
if args.config:
os.environ["GOVOPLAN_SERVER_CONFIG"] = args.config
config_path = args.config or os.getenv("GOVOPLAN_SERVER_CONFIG")
runtime_root = apply_runtime_defaults(config_path)
config = load_server_config(config_path)
registry = build_platform_registry(config.enabled_modules, manifest_factories=config.manifest_factories)
reload_dirs = build_reload_dirs(config, config_path=config_path, registry=registry, extra_dirs=args.reload_dir)
state = prepare_devserver(config_path, extra_reload_dirs=args.reload_dir)
print_devserver_summary(state, app=args.app, no_reload=args.no_reload)
print(f"Starting GovOPlaN dev server: {args.app}")
print(f"Config: {config_path or DEFAULT_CONFIG}")
if runtime_root is not None:
print(f"Runtime root: {runtime_root}")
print("Modules: " + ", ".join(manifest.id for manifest in registry.manifests()))
if args.no_reload:
print("Reload: disabled")
else:
print("Reload dirs:")
for directory in reload_dirs:
print(f" - {directory}")
if args.smoke:
run_smoke_check(state)
return 0
try:
import uvicorn
@@ -213,7 +330,7 @@ def main(argv: Sequence[str] | None = None) -> int:
host=args.host,
port=args.port,
reload=not args.no_reload,
reload_dirs=reload_dirs if not args.no_reload else None,
reload_dirs=state.reload_dirs if not args.no_reload else None,
)
return 0