-
Notifications
You must be signed in to change notification settings - Fork 0
Lifecycle
PluginForge manages plugin lifecycle through several gated phases between discovered and active. The v0.6.0 work introduced structured per-phase diagnostics (PluginError with phase, FilterReason for skip-paths); v0.7.0 added the identity_check phase.
[Discovered]
|
+--> identity_check (v0.7.0) --> filter: wrong_application | warn (deprecation)
|
+--> version_check --> filter: incompatible_api_version |
| incompatible_app_version | warn
|
+--> dependency_check --> filter: dependency_unmet
|
+--> init() --> filter: load_failed (on raise or config_schema fail)
|
+--> pre_activate (if configured) --> filter: pre_activate_rejected (False return)
| | filter: load_failed (raised)
|
+--> activate() --> filter: load_failed (on raise)
|
[Active]
|
refresh_config() <-- config snapshot replaced
| <-- on_config_changed(old, new) fired
[Active, new config]
|
deactivate()
|
[Deactivated]
|
reload_plugin() | rediscover()
|
[Re-Active or Removed]
Each gate that filters a plugin records a PluginError(phase=..., severity=...) in DiscoveryResult.errors and sets PluginState.filter_reason. See Discovery and Dependencies for the full taxonomy.
When the host has declared app_id and the plugin has declared target_application:
- Match -> proceed
- Mismatch -> filter with
wrong_application,PluginError(phase="identity_check", severity="error")
When the plugin has not declared target_application:
- If host has declared
app_id: filter withmissing_target_application,PluginError(phase="identity_check", severity="error")(v0.9.0; was a deprecation warning in v0.7.0/v0.8.0). - If host has not declared
app_id: activate silently. The v0.7.0 deprecation warning was retired in v0.9.0 now that the hard-filter transition has shipped.
See BasePlugin for the full behavior matrix and docs/design/v0.9.0-lifecycle-visibility.md for the transition rationale.
api_version and min_app_version are compared against the manager's api_version and app_version. Severity is configured per check (api_version_severity, app_version_severity):
-
"warning"(api_version default): log and proceed; warning is still recorded inDiscoveryResult.errors. -
"error"(app_version default, whenapp_versionis set): filter withincompatible_api_versionorincompatible_app_version.
When app_version is not set on the manager, min_app_version is ignored regardless of plugin declarations.
depends_on targets are resolved against the discovered set. Missing dependencies filter the dependent plugin with PluginError(phase="dependency_check", severity="error") and filter_reason="dependency_unmet". Circular dependencies raise CircularDependencyError at sort time.
plugin.init(app_config, plugin_config) is called. The plugin receives both the global app config and its plugin-specific config from YAML. The default implementation stores both as self.app_config and self.config.
On failure (raise or config_schema validation): filter with PluginError(phase="activation", severity="error") and filter_reason="load_failed". The underlying exception is preserved in PluginError.cause.
If a pre_activate callback is configured on the PluginManager, it is called with (plugin, plugin_config) before activation.
-
Falsereturn -> filter withfilter_reason="pre_activate_rejected",PluginError(phase="pre_activate", cause=None). Plugin not registered with pluggy. - Raised exception -> filter with
filter_reason="load_failed",PluginError(phase="pre_activate", cause=<exception>). Distinguishable from theFalsereturn case via thecausefield (see v0.6.0 design doc).
Common uses: license validation, permission checks, environment checks.
plugin.activate() is called after init and pre-activate. The plugin is registered with pluggy; its @hookimpl methods become callable.
On failure: filter with PluginError(phase="activation", severity="error"), filter_reason="load_failed". Plugin is unregistered from pluggy.
plugin.deactivate() is called on shutdown or explicit deactivation. The plugin is unregistered from pluggy; the active list no longer contains it.
deactivate_all() processes plugins in reverse activation order (LIFO) so dependents are deactivated before their dependencies.
On failure: the error is logged, but deactivation continues. Plugin is considered deactivated even if deactivate() raised. One broken plugin does not block shutdown.
plugin.on_config_changed(old, new) is called when PluginManager.refresh_config() replaces the app-config snapshot. See "Live config refresh" below.
errors = pm.refresh_config(new_app_config) # or refresh_config() to reload app.yamlFlow:
- Manager's
_app_configis replaced with the new dict (or reloaded from disk if no argument). - i18n cache is refreshed (only if reloading from disk).
- For each active plugin:
plugin.app_configreference is reassigned to the new dict. - For each active plugin:
plugin.on_config_changed(old, new)is called. -
(v0.9.0) For each plugin whose
on_config_changedreturned without raising:PluginState.last_config_changeis updated to the current UTC time on the unified state tracker, and theon_config_refreshed(name, old, new)event hook fires for each registered subscriber. - Any exception from
on_config_changedproduces aPluginError(phase="config_refresh", severity="error"). The error is collected and the failing plugin is skipped for bothlast_config_changeandon_config_refreshedevent firing. Returned to the caller aslist[PluginError].
Semantics: lazy. Plugins read self.config and self.app_config at handler-call time, so changes to the snapshot affect future operations automatically. The on_config_changed hook is opt-in for plugins that need to react actively (cache invalidation, reconnecting services, log level changes).
In-flight work: the new config applies to future operations, not in-flight ones. Stateful plugins (job managers, open connections) should be designed to either drain or accept that ongoing work uses the old config.
See PluginManager#refresh_config and the v0.6.0 design doc for the full rationale; see PluginManager#event-hooks-v090 for the on_config_refreshed consumer-side hook (distinct from on_config_changed, which is the plugin-side hook).
PluginManager exposes three registration-based callbacks consumers can subscribe to for observing lifecycle transitions. Each registration returns an idempotent deregistration closure.
unreg_a = pm.on_plugin_activated(lambda name: ...)
unreg_d = pm.on_plugin_deactivated(lambda name: ...)
unreg_c = pm.on_config_refreshed(lambda name, old, new: ...)-
Fires after success only. Callbacks invoke after the underlying lifecycle step completed without raising. Failures (activation raise,
on_config_changedraise, etc.) skip the callback. - Snapshot-at-dispatch semantics. The subscriber list is snapshotted when an event begins dispatching; callbacks registered during a callback's execution land in the list but fire on the next event, not the current dispatch loop.
- Failures swallowed. A callback that raises is logged at WARNING and the next subscriber still fires; the lifecycle pipeline is unaffected.
-
LIFO during
deactivate_all. When the manager tears down,on_plugin_deactivatedfires per plugin in reverse-activation order.
See PluginManager#event-hooks-v090 for the full signatures.
PluginState carries three v0.9.0 fields populated by the manager:
-
activated_at: datetime | None- UTC timestamp set on successful activation. Retained on subsequentdeactivate_plugin/deactivate_all(onlyrediscoverremoval purges the entry). -
last_config_change: datetime | None- UTC timestamp updated whenrefresh_confignotifies this plugin andon_config_changedreturns without raising. Not updated for plugins whose hook raised or for inactive plugins. -
source: Literal["entry_point", "direct_register"] | None- discovery route.Noneonly for plugins named inenabledconfig but never found via entry points (filter_reason="not_discovered").
For a single-call snapshot that includes the state plus config / health / hooks / routes / dependencies, use PluginManager.inspect_plugin(name).
diff = pm.rediscover()Flow:
- Invalidate
importlibandimportlib.metadatacaches so newly-installed distributions become visible. - Re-read entry points from the configured group.
- Diff against currently active plugins.
-
Removed names (active before, no longer discoverable): full
deactivate()cycle, unregister from pluggy. -
Added names (newly discovered): full activation pipeline (identity, version, dependency checks, init, pre-activate, activate). These produce per-plugin
PluginStateentries indiff.states. -
Unchanged names: untouched. Module code is not reloaded; use
reload_plugin(name)for that.
The returned DiscoveryDiff has added, removed, unchanged, states (for newly-discovered names only), and errors.
Canonical use case: picking up a plugin installed mid-process (poetry install in another shell) without restarting the host application.
reload_plugin(name) performs a full cycle on one plugin:
-
deactivate()the running plugin - Unregister from pluggy
- Re-import the plugin's Python module from disk
- Re-instantiate the plugin class
-
init()with current config - Run
pre_activatecheck (if configured) - Register with pluggy
-
activate()the new instance
The old plugin instance is discarded. State held in the old instance is lost; if you need persistent state across reloads, store it externally.
Distinct from rediscover(): reload_plugin re-imports module code for an existing plugin; rediscover re-reads entry points but does not touch existing plugins' code.
Each lifecycle phase is wrapped in error handling. The structured outcome is recorded in PluginError and PluginState:
| Phase | On failure | filter_reason |
PluginError.phase |
|---|---|---|---|
| identity check (v0.7.0) | Mismatch | wrong_application |
identity_check |
| identity check (v0.9.0) | Host has app_id, plugin missing target_application
|
missing_target_application |
identity_check (was a warning in v0.7.0/v0.8.0) |
| version check |
api_version_severity="error" and mismatch |
incompatible_api_version |
version_check |
| version check |
app_version_severity="error" and below min_app_version
|
incompatible_app_version |
version_check |
| dependency check | Missing target in depends_on
|
dependency_unmet |
dependency_check |
| init / config_schema | Raise or schema mismatch | load_failed |
activation |
| pre_activate | Return False
|
pre_activate_rejected |
pre_activate (cause=None) |
| pre_activate | Raised exception | load_failed |
pre_activate (cause=<exception>) |
| activate | Raise | load_failed |
activation |
| deactivate | Raise | (logged, plugin still removed) | n/a |
| on_config_changed | Raise | (collected in refresh_config return) |
config_refresh |
| hook execution | Raise | (logged, other hooks still execute) | n/a |
Errors in one plugin never prevent other plugins from being processed.
When calling hooks, a single plugin's hook implementation can fail without affecting others:
# Standard call - if any implementation throws, returns []
results = pm.call_hook("on_save", document=doc)
# Safe call - calls each implementation individually, skips failures
results = pm.call_hook_safe("on_save", document=doc)call_hook_safe() is recommended for non-critical hooks where partial results are acceptable. Use call_hook() for critical hooks where all-or-nothing semantics are needed.
The PluginLifecycle class tracks state internally:
lifecycle = PluginLifecycle()
# Init / Activate / Deactivate (each returns bool)
lifecycle.init_plugin(plugin, app_config, plugin_config)
lifecycle.activate_plugin(plugin)
lifecycle.deactivate_plugin(plugin)
# Query
lifecycle.is_active("export") # -> bool
lifecycle.get_plugin("export") # -> BasePlugin | None
lifecycle.get_active_plugins() # -> list[BasePlugin]
# Last-error retrieval (v0.6.0) - lets the manager populate PluginError.cause
lifecycle.get_last_error("export") # -> Exception | None
# Bulk deactivate in reverse order
lifecycle.deactivate_all()
# Remove (used during hot-reload)
lifecycle.remove_plugin("export") # clears all trackingget_last_error(name) was added in v0.6.0 so the manager can preserve the underlying exception cause when building PluginError from a failed init_plugin / activate_plugin call. The bool return on those methods is preserved for back-compat; the exception is available via this side-channel.
Plugin activation is dispatched by the manager via _activate_with_states (renamed from the pre-v0.6.0 _activate_ordered). The new name reflects that the method also builds the structured DiscoveryResult along the way.
pm = PluginManager(
"config/app.yaml",
app_version="1.5.0", # enables min_app_version gating
app_id="my-app", # enables target_application gating (v0.7.0)
)
result = pm.discover_plugins() # full activation pipeline
# Inspect outcomes
for name, reason in result.filtered_out().items():
print(f" {name}: {reason}")
for err in result.errors:
print(f" {err.severity.upper()} {err.name} [{err.phase}]: {err.user_facing_message}")
# ... application runs ...
# Live config refresh
pm.refresh_config() # reload from disk + notify plugins
# Pick up newly-installed plugins
diff = pm.rediscover()
print(f"Added: {diff.added}; Removed: {diff.removed}")
# Hot-reload during development
pm.reload_plugin("export")
# Shutdown
pm.deactivate_all()Query which hooks a plugin implements or which hook specs are registered:
hooks = pm.get_plugin_hooks("export")
all_hooks = pm.get_all_hook_names()See PluginManager for the full method list and related types.