Files
govoplan-core/src/govoplan_core/devserver.py
Albrecht Degering 635d25c74c
Some checks failed
Dependency Audit / dependency-audit (push) Has been cancelled
Module Matrix / module-matrix (push) Has been cancelled
chore: consolidate platform split checks
2026-07-10 12:51:19 +02:00

369 lines
15 KiB
Python

from __future__ import annotations
import argparse
import importlib.util
from dataclasses import dataclass
import inspect
import os
import sys
from collections.abc import Iterable, Sequence
from pathlib import Path
from typing import Any
from govoplan_core.core.discovery import iter_module_entry_points
from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids
from govoplan_core.core.modules import ModuleManifest
from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.db.session import configure_database
from govoplan_core.server.config import GovoplanServerConfig, import_object, load_server_config
from govoplan_core.server.registry import available_module_manifests, build_platform_registry
DEFAULT_APP = "govoplan_core.server.app:app"
DEFAULT_CONFIG = "govoplan_core.server.default_config:get_server_config"
DEFAULT_DEV_DATABASE_URL = "postgresql+psycopg://govoplan_dev@127.0.0.1:5432/govoplan_dev"
DEFAULT_DEV_DATABASE_URL_PGTOOLS = "postgresql://govoplan_dev@127.0.0.1:5432/govoplan_dev"
DEFAULT_SQLITE_DATABASE_NAME = "multimailer-dev.db"
@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:
return None
module_name = object_path.split(":", 1)[0]
try:
spec = importlib.util.find_spec(module_name)
except (ImportError, AttributeError, ValueError):
return None
if spec is None or not spec.origin:
return None
source = Path(spec.origin).resolve()
if source.name == "__init__.py":
package_dir = source.parent
else:
package_dir = source.parent
if package_dir.name == "app":
return package_dir.parent
return None
def apply_runtime_defaults(config_path: str | None) -> Path | 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)
dev_database_backend = os.getenv("GOVOPLAN_DEV_DATABASE_BACKEND", "postgres").strip().lower()
default_database_url = DEFAULT_DEV_DATABASE_URL
default_pgtools_url = DEFAULT_DEV_DATABASE_URL_PGTOOLS
if dev_database_backend == "sqlite":
default_database_url = f"sqlite:///{runtime_root / 'runtime' / DEFAULT_SQLITE_DATABASE_NAME}"
default_pgtools_url = ""
defaults = {
"DATABASE_URL": os.getenv("GOVOPLAN_DEV_DATABASE_URL", default_database_url),
"DEV_BOOTSTRAP_ENABLED": "true",
"FILE_STORAGE_LOCAL_ROOT": str(runtime_root / "runtime" / "files"),
"MOCK_MAILBOX_DIR": str(runtime_root / "runtime" / "mock-mailbox"),
}
for key, value in defaults.items():
os.environ.setdefault(key, value)
if default_pgtools_url:
os.environ.setdefault("GOVOPLAN_DATABASE_URL_PGTOOLS", os.getenv("GOVOPLAN_DEV_DATABASE_URL_PGTOOLS", default_pgtools_url))
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' / DEFAULT_SQLITE_DATABASE_NAME}."
)
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
if not directory.exists():
return None
for candidate in (directory, *directory.parents):
if candidate.name == "src":
return candidate
return directory
def _source_roots_for_object(value: Any) -> tuple[Path, ...]:
candidates: list[Any] = [value]
if not inspect.isclass(value) and not inspect.ismodule(value):
candidates.append(type(value))
roots: list[Path] = []
for candidate in candidates:
try:
source = inspect.getsourcefile(candidate) or inspect.getfile(candidate)
except (TypeError, OSError):
continue
root = _source_root_for_path(source)
if root is not None:
roots.append(root)
return tuple(roots)
def _manifest_source_roots(manifest: ModuleManifest) -> tuple[Path, ...]:
roots: list[Path] = []
if manifest.route_factory is not None:
roots.extend(_source_roots_for_object(manifest.route_factory))
if manifest.migration_spec is not None and manifest.migration_spec.script_location:
root = _source_root_for_path(manifest.migration_spec.script_location)
if root is not None:
roots.append(root)
for provider in manifest.resource_acl_providers:
roots.extend(_source_roots_for_object(provider))
for provider in manifest.tenant_summary_providers:
roots.extend(_source_roots_for_object(provider))
for providers in manifest.delete_veto_providers.values():
for provider in providers:
roots.extend(_source_roots_for_object(provider))
return tuple(roots)
def _entry_point_source_roots(enabled_module_ids: set[str]) -> tuple[Path, ...]:
roots: list[Path] = []
for entry_point in iter_module_entry_points():
loaded = entry_point.load()
manifest = loaded() if callable(loaded) else loaded
if not isinstance(manifest, ModuleManifest) or manifest.id not in enabled_module_ids:
continue
roots.extend(_source_roots_for_object(loaded))
return tuple(roots)
def _config_source_roots(config_path: str | None) -> tuple[Path, ...]:
object_path = config_path or os.getenv("GOVOPLAN_SERVER_CONFIG") or DEFAULT_CONFIG
try:
loaded = import_object(object_path)
except ModuleNotFoundError:
if config_path or os.getenv("GOVOPLAN_SERVER_CONFIG"):
raise
loaded = import_object(DEFAULT_CONFIG)
return _source_roots_for_object(loaded)
def unique_existing_dirs(paths: Iterable[Path | str]) -> list[str]:
seen: set[Path] = set()
result: list[str] = []
for item in paths:
path = Path(item).resolve()
if path in seen or not path.is_dir():
continue
seen.add(path)
result.append(str(path))
return result
def build_reload_dirs(
config: GovoplanServerConfig,
*,
config_path: str | None = None,
registry: PlatformRegistry | None = None,
extra_dirs: Sequence[str] = (),
) -> list[str]:
active_registry = registry or build_platform_registry(config.enabled_modules, manifest_factories=config.manifest_factories)
manifests = active_registry.manifests()
enabled_module_ids = {manifest.id for manifest in manifests}
roots: list[Path | str] = []
roots.extend(_config_source_roots(config_path))
for factory in config.manifest_factories:
roots.extend(_source_roots_for_object(factory))
roots.extend(_entry_point_source_roots(enabled_module_ids))
for manifest in manifests:
roots.extend(_manifest_source_roots(manifest))
roots.extend(extra_dirs)
return unique_existing_dirs(roots)
def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Run the GovOPlaN development server with module-aware reload directories.")
parser.add_argument("--config", help="GovoplanServerConfig object path, e.g. app.govoplan_config:get_server_config.")
parser.add_argument("--app", default=DEFAULT_APP, help=f"ASGI app import string passed to uvicorn. Default: {DEFAULT_APP}.")
parser.add_argument("--host", default="127.0.0.1", help="Host to bind. Default: 127.0.0.1.")
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 database_url:
configure_database(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)
raw_enabled_modules = load_startup_enabled_modules(config.enabled_modules)
candidate_modules = startup_candidate_module_ids(config.enabled_modules, raw_enabled_modules)
available_modules = available_module_manifests(
config.manifest_factories,
enabled_modules=candidate_modules,
ignore_load_errors=True,
)
enabled_modules = load_startup_enabled_modules(config.enabled_modules, available=available_modules)
registry = build_platform_registry(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.database_url.startswith("postgresql"):
pgtools_url = os.getenv("GOVOPLAN_DATABASE_URL_PGTOOLS")
if pgtools_url:
print(f"PostgreSQL tools URL: {pgtools_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")
state = prepare_devserver(config_path, extra_reload_dirs=args.reload_dir)
print_devserver_summary(state, app=args.app, no_reload=args.no_reload)
if args.smoke:
run_smoke_check(state)
return 0
try:
import uvicorn
except ModuleNotFoundError as exc:
raise SystemExit("uvicorn is not installed. Install govoplan-core with the server extra or requirements-dev.txt.") from exc
uvicorn.run(
args.app,
host=args.host,
port=args.port,
reload=not args.no_reload,
reload_dirs=state.reload_dirs if not args.no_reload else None,
)
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))