feat: support focused dev reload scopes
This commit is contained in:
15
README.md
15
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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user