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:]))