Skip to content
Asterios Raptis edited this page May 21, 2026 · 6 revisions

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.

State Machine

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

Phases

1. Identity check (v0.7.0)

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 with missing_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.

2. Version check

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 in DiscoveryResult.errors.
  • "error" (app_version default, when app_version is set): filter with incompatible_api_version or incompatible_app_version.

When app_version is not set on the manager, min_app_version is ignored regardless of plugin declarations.

3. Dependency check

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.

4. Init

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.

5. Pre-activate check

If a pre_activate callback is configured on the PluginManager, it is called with (plugin, plugin_config) before activation.

  • False return -> filter with filter_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 the False return case via the cause field (see v0.6.0 design doc).

Common uses: license validation, permission checks, environment checks.

6. Activate

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.

7. Deactivate

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.

8. Config refresh (v0.6.0)

plugin.on_config_changed(old, new) is called when PluginManager.refresh_config() replaces the app-config snapshot. See "Live config refresh" below.

Live config refresh (v0.6.0)

errors=pm.refresh_config(new_app_config) # or refresh_config() to reload app.yaml

Flow:

  1. Manager's _app_config is replaced with the new dict (or reloaded from disk if no argument).
  2. i18n cache is refreshed (only if reloading from disk).
  3. For each active plugin: plugin.app_config reference is reassigned to the new dict.
  4. For each active plugin: plugin.on_config_changed(old, new) is called.
  5. (v0.9.0) For each plugin whose on_config_changed returned without raising: PluginState.last_config_change is updated to the current UTC time on the unified state tracker, and the on_config_refreshed(name, old, new) event hook fires for each registered subscriber.
  6. Any exception from on_config_changed produces a PluginError(phase="config_refresh", severity="error"). The error is collected and the failing plugin is skipped for both last_config_change and on_config_refreshed event firing. Returned to the caller as list[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).

Event hooks (v0.9.0)

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(lambdaname: ...)
unreg_d=pm.on_plugin_deactivated(lambdaname: ...)
unreg_c=pm.on_config_refreshed(lambdaname, old, new: ...)
  • Fires after success only. Callbacks invoke after the underlying lifecycle step completed without raising. Failures (activation raise, on_config_changed raise, 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_deactivated fires per plugin in reverse-activation order.

See PluginManager#event-hooks-v090 for the full signatures.

State observability (v0.9.0)

PluginState carries three v0.9.0 fields populated by the manager:

  • activated_at: datetime | None - UTC timestamp set on successful activation. Retained on subsequent deactivate_plugin / deactivate_all (only rediscover removal purges the entry).
  • last_config_change: datetime | None - UTC timestamp updated when refresh_config notifies this plugin and on_config_changed returns without raising. Not updated for plugins whose hook raised or for inactive plugins.
  • source: Literal["entry_point", "direct_register"] | None - discovery route. None only for plugins named in enabled config 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).

Entry-point rediscovery (v0.6.0)

diff=pm.rediscover()

Flow:

  1. Invalidate importlib and importlib.metadata caches so newly-installed distributions become visible.
  2. Re-read entry points from the configured group.
  3. Diff against currently active plugins.
  4. Removed names (active before, no longer discoverable): full deactivate() cycle, unregister from pluggy.
  5. Added names (newly discovered): full activation pipeline (identity, version, dependency checks, init, pre-activate, activate). These produce per-plugin PluginState entries in diff.states.
  6. 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.

Hot-reload of a single plugin

reload_plugin(name) performs a full cycle on one plugin:

  1. deactivate() the running plugin
  2. Unregister from pluggy
  3. Re-import the plugin's Python module from disk
  4. Re-instantiate the plugin class
  5. init() with current config
  6. Run pre_activate check (if configured)
  7. Register with pluggy
  8. 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.

Error handling summary

Each lifecycle phase is wrapped in error handling. The structured outcome is recorded in PluginError and PluginState:

PhaseOn failurefilter_reasonPluginError.phase
identity check (v0.7.0)Mismatchwrong_applicationidentity_check
identity check (v0.9.0)Host has app_id, plugin missing target_applicationmissing_target_applicationidentity_check (was a warning in v0.7.0/v0.8.0)
version checkapi_version_severity="error" and mismatchincompatible_api_versionversion_check
version checkapp_version_severity="error" and below min_app_versionincompatible_app_versionversion_check
dependency checkMissing target in depends_ondependency_unmetdependency_check
init / config_schemaRaise or schema mismatchload_failedactivation
pre_activateReturn Falsepre_activate_rejectedpre_activate (cause=None)
pre_activateRaised exceptionload_failedpre_activate (cause=<exception>)
activateRaiseload_failedactivation
deactivateRaise(logged, plugin still removed)n/a
on_config_changedRaise(collected in refresh_config return)config_refresh
hook executionRaise(logged, other hooks still execute)n/a

Errors in one plugin never prevent other plugins from being processed.

Graceful degradation

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 failuresresults=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.

PluginLifecycle class

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)
# Querylifecycle.is_active("export") # -> boollifecycle.get_plugin("export") # -> BasePlugin | Nonelifecycle.get_active_plugins() # -> list[BasePlugin]# Last-error retrieval (v0.6.0) - lets the manager populate PluginError.causelifecycle.get_last_error("export") # -> Exception | None# Bulk deactivate in reverse orderlifecycle.deactivate_all()
# Remove (used during hot-reload)lifecycle.remove_plugin("export") # clears all tracking

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

Typical flow

pm=PluginManager(
"config/app.yaml",
app_version="1.5.0", # enables min_app_version gatingapp_id="my-app", # enables target_application gating (v0.7.0)
)
result=pm.discover_plugins() # full activation pipeline# Inspect outcomesforname, reasoninresult.filtered_out().items():
print(f" {name}: {reason}")
forerrinresult.errors:
print(f" {err.severity.upper()}{err.name} [{err.phase}]: {err.user_facing_message}")
# ... application runs ...# Live config refreshpm.refresh_config() # reload from disk + notify plugins# Pick up newly-installed pluginsdiff=pm.rediscover()
print(f"Added: {diff.added}; Removed: {diff.removed}")
# Hot-reload during developmentpm.reload_plugin("export")
# Shutdownpm.deactivate_all()

Introspection

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.

Clone this wiki locally