Skip to content

Lifecycle

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(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_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:

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.

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

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)

# 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 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 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()

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