feat: support focused dev reload scopes

This commit is contained in:
2026-07-29 18:52:55 +02:00
parent 790790ab37
commit a80caf7933
3 changed files with 174 additions and 5 deletions

View File

@@ -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.

View File

@@ -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:

View File

@@ -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"