ADR-013 Phase 0: plugin lifecycle groundwork
Additive, zero-risk-to-running-sites prep for the plugin catalog. No distribution or lean-build behavior yet; fixes latent bugs and adds the declarative + validate tooling later phases build on. Fixes: - upgrade_all_plugins iterates registry.get_all(); only adopted plugins are migrated. Removes the phantom hasattr(registry, 'list_installed') probe that always fell through to migrating every folder on disk (unadopted DDL ran with full DB rights on every deploy). - Reverse-dependency checks on uninstall/disable read dependencies from the manifest on disk via _installed_dependents, so an installed-but-unloaded or disabled dependent is counted. Uninstall blocks on any installed dependent; disable blocks on an enabled dependent. - _sort_by_dependencies detects a dependency cycle (back edge in the DFS) and raises PluginDependencyError instead of looping or dropping a plugin. New: - flask plugin validate <name>: manifest loads + name match, manifest-schema check, core_version admits the framework contract, declared dependencies exist on disk. No new dependency (lightweight checker); schema ships in the package at shopdb/plugins/manifest_schema.json (docs/ is stripped on publish). The check caught that provides is an object, not an array. - flask plugin apply-profile <file>: declarative install AND enable of a chosen plugin set plus its hard-dependency closure, in dependency order, idempotent. Replaces the hand-ordered runbook sequences that could enable a plugin that was never installed. deploy/site-profile.example.json template. - Dockerfile header corrected (all 13 catalog plugins, not "eleven core"). 10 new lifecycle tests (reverse-deps from disk, cycle detection, upgrade-all scope, profile closure, schema, all 13 manifests match schema). 1018 pass, naming green.
This commit is contained in:
@@ -242,24 +242,35 @@ class PluginLoader:
|
||||
"""Sort plugins so dependencies come first.
|
||||
|
||||
Reads dependencies from manifest.json directly; does not
|
||||
instantiate plugin classes during sort.
|
||||
instantiate plugin classes during sort. Detects a dependency cycle
|
||||
(a back edge in the DFS) and raises PluginDependencyError rather than
|
||||
looping or silently dropping a plugin.
|
||||
"""
|
||||
sorted_list = []
|
||||
visited = set()
|
||||
visited = set() # fully processed (post-order emitted)
|
||||
visiting = set() # on the current DFS stack; a revisit here is a cycle
|
||||
|
||||
def visit(name):
|
||||
if name in visited:
|
||||
return
|
||||
visited.add(name)
|
||||
|
||||
if name in visiting:
|
||||
raise PluginDependencyError(
|
||||
f'Circular plugin dependency involving "{name}"',
|
||||
plugin_name=name,
|
||||
)
|
||||
visiting.add(name)
|
||||
# Only the manifest read is tolerant; a cycle raised by a nested
|
||||
# visit must propagate, so the dep recursion sits outside the guard
|
||||
# (PluginDependencyError is itself a PluginError).
|
||||
try:
|
||||
manifest = self.load_manifest(name)
|
||||
for dep in manifest.get('dependencies', []):
|
||||
if dep in plugin_names:
|
||||
visit(dep)
|
||||
deps = self.load_manifest(name).get('dependencies', [])
|
||||
except PluginError:
|
||||
pass
|
||||
|
||||
deps = []
|
||||
for dep in deps:
|
||||
if dep in plugin_names:
|
||||
visit(dep)
|
||||
visiting.discard(name)
|
||||
visited.add(name)
|
||||
sorted_list.append(name)
|
||||
|
||||
for name in plugin_names:
|
||||
|
||||
Reference in New Issue
Block a user