diff --git a/README.md b/README.md index c4e572c..15631d7 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,21 @@ ENABLED_MODULES=access,campaigns /mnt/DATA/git/govoplan/.venv/bin/python -m govo The runner loads the same `GovoplanServerConfig` as `govoplan_core.server.app:app`, builds the platform registry, and passes core plus enabled module source roots to uvicorn as reload directories. After reinstalling the editable package, the same command is also available as `govoplan-devserver`. +For focused backend work, keep the complete module graph active while watching +only the module being edited. Core/config sources and explicit `--reload-dir` +paths remain watched: + +```bash +/mnt/DATA/git/govoplan/.venv/bin/python -m govoplan_core.devserver \ + --reload-module calendar \ + --reload-module campaign +``` + +Use `--reload-core-only` when no optional module source tree should trigger a +restart. Omitting both options preserves the broad default and watches every +enabled module. Startup, migration, and compatibility checks still run against +the complete enabled graph whenever the backend restarts. + The default development database is PostgreSQL at `postgresql+psycopg://govoplan_dev@127.0.0.1:5432/govoplan_dev`. Store the password in `~/.pgpass`. To force the disposable SQLite fallback, run with `GOVOPLAN_DEV_DATABASE_BACKEND=sqlite`; that database lives below `runtime/`. Local devserver runs do not require Redis. `CELERY_ENABLED` defaults to `false`, so campaign queue actions update database state without publishing Celery tasks. Use the synchronous send flow for local send tests, or set `CELERY_ENABLED=true` only when a Redis broker and worker are running. diff --git a/src/govoplan_core/devserver.py b/src/govoplan_core/devserver.py index 4c33b67..8186110 100644 --- a/src/govoplan_core/devserver.py +++ b/src/govoplan_core/devserver.py @@ -37,6 +37,7 @@ class DevserverState: config: GovoplanServerConfig registry: PlatformRegistry reload_dirs: list[str] + reload_module_ids: tuple[str, ...] | None def _config_module_runtime_root(config_path: str | None) -> Path | None: @@ -237,20 +238,44 @@ def build_reload_dirs( config_path: str | None = None, registry: PlatformRegistry | None = None, extra_dirs: Sequence[str] = (), + module_ids: Sequence[str] | None = None, ) -> 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} + selected_module_ids = ( + enabled_module_ids + if module_ids is None + else {module_id.strip() for module_id in module_ids if module_id.strip()} + ) + unknown_module_ids = selected_module_ids - enabled_module_ids + if unknown_module_ids: + raise SystemExit( + "Reload modules are not enabled: " + + ", ".join(sorted(unknown_module_ids)) + ) roots: list[Path | str] = [] roots.extend(_config_source_roots(config_path)) for factory in config.manifest_factories: + try: + manifest = factory() + except TypeError: + manifest = None + if ( + module_ids is not None + and isinstance(manifest, ModuleManifest) + and manifest.id not in selected_module_ids + ): + continue roots.extend(_source_roots_for_object(factory)) - roots.extend(_entry_point_source_roots(enabled_module_ids)) + roots.extend(_entry_point_source_roots(selected_module_ids)) for manifest in manifests: + if manifest.id not in selected_module_ids: + continue roots.extend(_manifest_source_roots(manifest)) roots.extend(extra_dirs) @@ -265,11 +290,32 @@ 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.") + reload_scope = parser.add_mutually_exclusive_group() + reload_scope.add_argument( + "--reload-module", + action="append", + default=None, + metavar="MODULE_ID", + help=( + "Watch only this enabled module in addition to core/config sources. " + "May be passed multiple times." + ), + ) + reload_scope.add_argument( + "--reload-core-only", + action="store_true", + help="Watch core/config sources but no optional module source trees.", + ) 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: +def prepare_devserver( + config_path: str | None, + *, + extra_reload_dirs: Sequence[str] = (), + reload_module_ids: Sequence[str] | None = None, +) -> DevserverState: runtime_root = apply_runtime_defaults(config_path) database_url = os.getenv("DATABASE_URL", "") validate_sqlite_database_url(database_url) @@ -291,7 +337,13 @@ def prepare_devserver(config_path: str | None, *, extra_reload_dirs: Sequence[st ) 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) + reload_dirs = build_reload_dirs( + config, + config_path=config_path, + registry=registry, + extra_dirs=extra_reload_dirs, + module_ids=reload_module_ids, + ) return DevserverState( config_path=config_path, runtime_root=runtime_root, @@ -300,6 +352,11 @@ def prepare_devserver(config_path: str | None, *, extra_reload_dirs: Sequence[st config=config, registry=registry, reload_dirs=reload_dirs, + reload_module_ids=( + None + if reload_module_ids is None + else tuple(sorted(set(reload_module_ids))) + ), ) @@ -320,6 +377,15 @@ def print_devserver_summary(state: DevserverState, *, app: str, no_reload: bool) if no_reload: print("Reload: disabled") else: + if state.reload_module_ids is None: + print("Reload scope: core/config plus all enabled modules") + elif state.reload_module_ids: + print( + "Reload scope: core/config plus " + + ", ".join(state.reload_module_ids) + ) + else: + print("Reload scope: core/config only") print("Reload dirs:") for directory in state.reload_dirs: print(f" - {directory}") @@ -353,7 +419,14 @@ def main(argv: Sequence[str] | None = None) -> int: 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) + reload_module_ids: Sequence[str] | None = args.reload_module + if args.reload_core_only: + reload_module_ids = () + state = prepare_devserver( + config_path, + extra_reload_dirs=args.reload_dir, + reload_module_ids=reload_module_ids, + ) print_devserver_summary(state, app=args.app, no_reload=args.no_reload) if args.smoke: diff --git a/tests/test_devserver.py b/tests/test_devserver.py index a2d4fc3..e3c34f1 100644 --- a/tests/test_devserver.py +++ b/tests/test_devserver.py @@ -6,11 +6,92 @@ import sys import tempfile import unittest from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch -from govoplan_core.devserver import redacted_database_url +from govoplan_core.devserver import build_reload_dirs, redacted_database_url class DevserverSmokeTests(unittest.TestCase): + def test_reload_dirs_can_be_limited_to_selected_modules(self) -> None: + with tempfile.TemporaryDirectory(prefix="govoplan-reload-") as directory: + root = Path(directory) + core_root = root / "core" + calendar_root = root / "calendar" + mail_root = root / "mail" + extra_root = root / "extra" + for path in (core_root, calendar_root, mail_root, extra_root): + path.mkdir() + + manifests = ( + SimpleNamespace(id="calendar"), + SimpleNamespace(id="mail"), + ) + config = SimpleNamespace(enabled_modules=(), manifest_factories=()) + registry = SimpleNamespace(manifests=lambda: manifests) + + def manifest_roots(manifest: object) -> tuple[Path, ...]: + return ( + calendar_root + if getattr(manifest, "id") == "calendar" + else mail_root, + ) + + with ( + patch( + "govoplan_core.devserver._config_source_roots", + return_value=(core_root,), + ), + patch( + "govoplan_core.devserver._entry_point_source_roots", + return_value=(), + ), + patch( + "govoplan_core.devserver._manifest_source_roots", + side_effect=manifest_roots, + ), + ): + broad = build_reload_dirs(config, registry=registry) + focused = build_reload_dirs( + config, + registry=registry, + module_ids=("calendar",), + extra_dirs=(str(extra_root),), + ) + core_only = build_reload_dirs( + config, + registry=registry, + module_ids=(), + ) + + self.assertEqual( + {str(core_root), str(calendar_root), str(mail_root)}, + set(broad), + ) + self.assertEqual( + {str(core_root), str(calendar_root), str(extra_root)}, + set(focused), + ) + self.assertEqual([str(core_root)], core_only) + + def test_reload_dirs_reject_disabled_module_selector(self) -> None: + config = SimpleNamespace(enabled_modules=(), manifest_factories=()) + registry = SimpleNamespace( + manifests=lambda: (SimpleNamespace(id="calendar"),) + ) + with ( + patch( + "govoplan_core.devserver._config_source_roots", + return_value=(), + ), + self.assertRaisesRegex(SystemExit, "mail"), + ): + build_reload_dirs( + config, + registry=registry, + module_ids=("mail",), + ) + def test_database_url_redaction_hides_passwords(self) -> None: rendered = redacted_database_url( "postgresql+psycopg://govoplan:database-secret@db.example.test/govoplan"