From 357bb6480dba12cb5a7bb0c15dc7e1d9849b6fc4 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 12 Oct 2026 07:23:58 -0400 Subject: [PATCH] fix(backup): restore finds plugin tables by path, as the app loads them (#5674) Run 9075: the restore got through schema and data, then all_metadata's `import plugins` failed in the image, where plugins/ is a directory the app loads by path (PLUGIN_DIRS), not an installed package. It now loads each plugin under the loader's own module name from the bootstrap's plugin dirs, which `steward restore` passes. A plugin whose models are already mapped under either name is skipped rather than mapped twice. Co-Authored-By: Claude Opus 5.5 --- steward/backup/restore.py | 38 ++++++++++++++++++++++---------------- steward/cli.py | 3 ++- 2 files changed, 24 insertions(+), 17 deletions(-) diff --git a/steward/backup/restore.py b/steward/backup/restore.py index de4b0d0..c1799e0 100644 --- a/steward/backup/restore.py +++ b/steward/backup/restore.py @@ -36,26 +36,32 @@ class Restored: fixed: int # rows put right between data and constraints -def all_metadata() -> MetaData: - """Steward's tables, every bundled plugin's included, without an app: - the restore runs before anything else would load them.""" +def all_metadata(plugin_dirs: list[Path] | None = None) -> MetaData: + """Steward's tables, every plugin's included, without an app: the + restore runs before anything else would load them. Plugins are found in + `plugin_dirs` (the bootstrap's, bundled first) and loaded by path under + the same module names the app gives them, so a plugin the app already + loaded isn't mapped twice. Without dirs: the repository's plugins/.""" import importlib - import pkgutil - import sys - import plugins + import steward import steward.models # noqa: F401 — registers core tables + from steward.core.plugin_manager import _import_plugin, resolve_plugin_path from steward.models.base import Base - for info in pkgutil.iter_modules(plugins.__path__): - # A plugin the app already loaded is under the loader's name; its - # models imported a second time would map every table twice. - loaded = f"_steward_plugin_{info.name}" - package = loaded if loaded in sys.modules else f"plugins.{info.name}" + + roots = [Path(d).resolve() for d in (plugin_dirs or [Path(steward.__file__).parent.parent / "plugins"])] + names = sorted({p.name for root in roots if root.is_dir() for p in root.iterdir() + if (p / "__init__.py").exists() and (p / "models.py").exists()}) + for name in names: + if f"plugins.{name}.models" in sys.modules or f"_steward_plugin_{name}.models" in sys.modules: + continue # already mapped, under one name or the other + path = resolve_plugin_path(roots, name) try: - importlib.import_module(f"{package}.models") - except ImportError: # no models, or an optional dependency missing - continue + package = _import_plugin(name, path) + importlib.import_module(f"{package.__name__}.models") + except Exception: # a plugin that won't load: its tables are left as pg_restore put them + logger.warning("Restore: couldn't load plugin %r's tables", name, exc_info=True) return Base.metadata @@ -167,7 +173,7 @@ async def _execute(database_url: str, statements: list[str]) -> int: async def restore(path: Path, database_url: str, *, wipe: bool = False, - metadata: MetaData | None = None) -> Restored: + metadata: MetaData | None = None, plugin_dirs: list[Path] | None = None) -> Restored: """Restore a backup file into the database at `database_url`. It must be empty unless `wipe`, which drops everything in its public schema first.""" existing = await _tables(database_url) @@ -182,6 +188,6 @@ async def restore(path: Path, database_url: str, *, wipe: bool = False, info, dump, key = await asyncio.to_thread(read_backup, path, Path(tmp)) await _pg_restore(database_url, dump, "pre-data") await _pg_restore(database_url, dump, "data") - fixed = await _execute(database_url, dangling_fixes(metadata or all_metadata())) + fixed = await _execute(database_url, dangling_fixes(metadata or all_metadata(plugin_dirs))) await _pg_restore(database_url, dump, "post-data") return Restored(info, key, fixed) diff --git a/steward/cli.py b/steward/cli.py index cd4fce7..92458db 100644 --- a/steward/cli.py +++ b/steward/cli.py @@ -96,7 +96,8 @@ def restore_command(ctx: click.Context, backup_file: Path, wipe: bool) -> None: boot = load_bootstrap(Path(ctx.obj["config"])) try: - done = asyncio.run(restore(backup_file, boot["database_url"], wipe=wipe)) + done = asyncio.run(restore(backup_file, boot["database_url"], wipe=wipe, + plugin_dirs=[Path(d) for d in boot["plugin_dirs"]])) except BackupError as exc: raise click.ClickException(str(exc)) from None made = done.manifest.get("created_at", "?")