Release v0.1.2
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user