Skip to content

Releases: astrapi69/pluginforge

v0.10.0: Consolidation

21 May 12:07
cf9d12a

Choose a tag to compare

Design doc: docs/design/v0.10.0-consolidation.md

[0.10.0] - 2026-05-21

Consolidation release. Four consecutive feature releases (v0.6.0 lifecycle, v0.7.0 identity, v0.8.0 testing helpers, v0.9.0 lifecycle visibility) grew the public surface; v0.10.0 is the first deliberate pause. Theme: API quality over API surface. Every change has a concrete consumer report behind it. See docs/design/v0.10.0-consolidation.md for the locked design decisions and resolved open questions (Q1-Q4).

Added

  • PluginManager.merge_app_config(overlay, *, notify=True). Recursive dict-merge of an overlay onto the current app-config snapshot (dicts merge; lists, scalars, and None replace). When notify=True (default), fires on_config_changed on active plugins via the same codepath as refresh_config. When notify=False, replaces the snapshot silently - appropriate at startup before discover_plugins() when no plugins are active to notify. Pre-existing keys not mentioned in the overlay are preserved (the semantic distinction from refresh_config, which replaces the entire snapshot). The internal _deep_merge helper is module-private and NOT exposed as a public utility; consumers needing custom merge semantics pre-compute their merged dict and use refresh_config(merged_dict, notify=...).
  • PluginManager.refresh_config gains a notify: bool = True keyword argument. Setting notify=False replaces the snapshot without firing on_config_changed; the return value is always []. Covers consumers (AdaptivLearner) that push a pre-computed full snapshot at startup and do not want to trigger hooks.
  • DiscoveryDiff.by_filter_reason(reason) -> list[str]. Parity helper mirroring DiscoveryResult.by_filter_reason. Bibliogon consumers who iterated diff.states.items() after every rediscover() to group by filter reason can now use the one-line accessor.
  • FilterReason, ErrorPhase, and config_diff are re-exported at the package root via pluginforge.__all__. The v0.6.0 CHANGELOG documented them as public API but pluginforge/__init__.py only exposed them via deep imports from pluginforge.state and pluginforge.utils. Both forms keep working alongside the canonical top-level import: from pluginforge import FilterReason, ErrorPhase, config_diff.
  • tests/test_public_api.py regression-pins __all__ against the documented public surface. Future drift surfaces in CI.

Changed

  • Single-router deprecation timeline. The v0.8.0 announcement said the multi-router warning "may become an error in v0.10.0"; the wording committed nothing, and at least one downstream consumer (Bibliogon's export plugin, three routers) has the refactor backlogged. Updated wording across pluginforge/fastapi_ext.py (the _MULTI_ROUTER_WARNING text), docs/guides/plugin-author.md (the deprecation-timeline list), and docs/guides/consumer-integration.md (the v0.7 -> v0.8 migration section) to "supported in v0.8.0 through v0.10.0. Earliest error transition is v0.11.0." No behavior change; the warning still fires.

Fixed

  • Stale pip install pluginforge[fastapi] prose scrubbed from README.md and examples/README.md. The [fastapi] and [alembic] extras were removed in v0.6.0 (commit aa9a009) as non-functional shims; the prose lagged for four releases. The new wording installs FastAPI as a normal dep of the consuming application, with a one-line note pointing at the v0.6.0 removal. Both Bibliogon and pluginforge-app-template reported tripping over this during adoption.

Housekeeping

  • twine added to [tool.poetry.group.dev.dependencies]. poetry run twine check dist/* is now reproducible inside the project venv without pipx-or-system-pip. Adding this triggered a poetry lock re-resolve of several dev tools (pytest 9 -> 8.4, pre-commit 4.6 -> 3.8, pytest-cov 7 -> 5, ruff 0.15 -> 0.4); these were corrections to bring the lockfile back inside the existing caret-pin windows (pytest = "^8.0", etc.) declared in pyproject.toml. No constraint changes; the lock had drifted above the declared ranges. Tests + ruff stay green.
  • Release-workflow doc: cd .. instruction added to the TestPyPI smoke-install block in .claude/rules/release-workflow.md. Closes the methodology loop from the v0.8.0 release where running the smoke-install from inside the repo tree silently leaked the local source onto the import path.
  • docs/ARCHITECTURE.md audit closes #13. Document was at v0.5.0 state; updated to reflect v0.6.0 through v0.10.0 surface (target_application, min_app_version, on_config_changed, structured DiscoveryResult / DiscoveryDiff / PluginState / PluginError / PluginInspection, identity gating, lifecycle visibility, event hooks, config-overlay). Section 7 ("Aktueller Stand") replaced its stale hardcoded test count with a single-source-of-truth pointer block (per .claude/rules/ai-workflow.md "Numeric claims verification"). The 7-section structure was sound; this is a content audit, not a structural rewrite.

Migration notes

  • merge_app_config replaces the _app_config assignment hack. Two independent consumers (pluginforge-app-template and AdaptivLearner) had separately developed the same manager._app_config = merged # type: ignore[attr-defined] pattern to overlay user config on top of the YAML-loaded snapshot. Migrate to manager.merge_app_config(user_overlay, notify=False) at startup (before discover_plugins()) and manager.merge_app_config(user_overlay) after Settings-UI writes. The type: ignore[attr-defined] block disappears. The private-attribute write keeps working in v0.10.0 but is slated for removal at v1.0.0; migrate before then.

  • refresh_config callers are unchanged unless they want the new kwarg. Existing pm.refresh_config(new_dict) calls keep their existing notify-active-plugins behavior. Only callers who explicitly pass notify=False see the new path.

  • Public-API re-exports are additive. from pluginforge.state import FilterReason, ErrorPhase and from pluginforge.utils import config_diff keep working. The new canonical form is from pluginforge import FilterReason, ErrorPhase, config_diff.

  • Single-router warning wording is the only docs change for plugin authors. No code-behavior change; if your plugin emits the deprecation warning today, it still does in v0.10.0. The earliest error-transition is v0.11.0.

  • Severity literal stays private. Q2 of the design doc explicitly resolved against adding Severity to __all__. Consumers comparing error.severity == "warning" / == "error" as plain strings is the intended pattern.

Test counts (verified)

  • Before this release: 325 passed, 1 skipped, 96% coverage on pluginforge/ (verified at the C1 baseline).
  • After this release: 340 passed, 1 skipped, 96% coverage. Per .claude/rules/ai-workflow.md "Numeric claims verification", these numbers come from poetry run pytest --collect-only -q | tail -5, poetry run pytest --tb=no -q | tail -3, and poetry run pytest --cov=pluginforge --cov-report=term | tail -3 run at the release commit; future numbers must be re-verified at the release commit and not carried forward from this changelog entry.

Explicitly NOT shipped in v0.10.0

Per the design doc's "Explicitly NOT in v0.10.0" section: P1.2 Service-Registry (no validated use case), P3.1 Hot-Reload / ZIP unification (no second consumer), P3.2 plugin metrics (no consumer signal), P3.3 bidirectional compatibility matrix (precondition pending), no new FilterReason values, no new PluginState fields, no new event hooks. These items remain candidates for future releases when a concrete consumer report justifies the surface addition.

v0.9.0: Lifecycle visibility + hard-filter transition

21 May 08:00
dbfdd33

Choose a tag to compare

For full design rationale and the resolved open questions, see docs/design/v0.9.0-lifecycle-visibility.md.

Lifecycle visibility release. Two clean legs: richer per-plugin state with an aggregator and event hooks (P1.1), plus the long-deferred hard-filter transition for the v0.7.0 application-identity work originally planned for v0.8.0.

Added

Lifecycle visibility (P1.1):

  • PluginState.activated_at: datetime | None. UTC timestamp set by the manager when a plugin successfully activates. Retained on subsequent deactivate_plugin / deactivate_all. Cleared (along with the rest of the state entry) only by rediscover removal.
  • PluginState.last_config_change: datetime | None. UTC timestamp updated when refresh_config notifies this plugin via on_config_changed and the hook completes without raising. Not updated for plugins whose hook raised, and not updated for inactive plugins (which are not notified).
  • PluginState.source: Literal["entry_point", "direct_register"] | None. Discovery route: "entry_point" for discover_plugins and rediscover, "direct_register" for register_plugins and register_plugin. None for plugins named in enabled config but never found via entry points (filter_reason="not_discovered").
  • PluginInspection frozen dataclass aggregating name, version, target_application, min_app_version, state (full PluginState), config, health, hooks, routes, dependencies. Exported from pluginforge package.
  • PluginManager.inspect_plugin(name) -> PluginInspection | None. Single-call replacement for the union of get_plugin / get_last_discovery_result / get_plugin_hooks / plugin.health() / plugin.config accessors. Returns None for unknown names; returns a snapshot with safe defaults for plugins filtered before initialization.
  • PluginManager.on_plugin_activated(callback) -> deregistration closure. Synchronous callback fired after each successful activation. callback(plugin_name).
  • PluginManager.on_plugin_deactivated(callback) -> deregistration closure. Fired after each successful deactivation (including LIFO traversal during deactivate_all). callback(plugin_name).
  • PluginManager.on_config_refreshed(callback) -> deregistration closure. Fired per plugin whose on_config_changed completed without raising during refresh_config. callback(plugin_name, old_app_config, new_app_config).

Changed

Hard-filter transition (deferred from v0.8.0):

  • FilterReason gains "missing_target_application". Plugins that have not declared target_application are now filtered with this reason at activation when the host has declared app_id. The v0.7.0 deprecation cycle has ended.
  • PluginManager._check_identity returns severity="error" instead of severity="warning" for the host-has-app_id + plugin-missing-target_application row (matrix row 3 of the v0.7.0 design doc). Plugin does not activate. Filter reason: "missing_target_application".
  • PluginManager._check_identity no longer emits a deprecation warning for the both-undeclared row (matrix row 5 of the v0.7.0 design doc). Silent activate. The v0.7.0 deprecation warning has been retired now that the transition has shipped.
  • Permissive semantics preserved: hosts that have not declared app_id see no behavior change.

Event-callback contract in refresh_config:

  • refresh_config now updates PluginState.last_config_change on the unified state tracker for each plugin whose on_config_changed completed without raising. Plugins whose hook raised are skipped for both the timestamp update and the new on_config_refreshed event firing.

Migration notes

  • Lifecycle-visibility fields are additive; no migration required. Existing consumers reading PluginState see the new fields with default None values.

  • Event-hook adoption is opt-in. A manager with no registered callbacks has zero overhead.

  • Hard-filter transition (plugin authors): if your plugin does not declare target_application on its BasePlugin subclass, it will now be filtered when loaded into a host that has adopted app_id. Declare target_application = "your-host-app-id" on the class. This is the long-promised v0.7.0 contract: see docs/design/v0.7.0-application-identity.md, Decision 4 for the original deprecation-cycle rationale.

  • Hard-filter transition (host application owners): if your host has not declared app_id on PluginManager, you see no behavior change in v0.9.0. The v0.6.0 min_app_version / app_version permissive-semantics pattern still holds; a host can run v0.6.x through v0.9.0 without ever adopting app_id and continue to load plugins as it always has.

  • v0.7.0 deprecation warning is gone. Code that was filtering result.errors by severity == "warning" and phase == "identity_check" will see no such entries in v0.9.0.

v0.8.0: Stability and tooling

20 May 16:01
271b3a9

Choose a tag to compare

Triage and locked architecture decisions: https://github.com/astrapi69/pluginforge/blob/main/docs/design/v0.8.0-improvements-triage-2026-05-20.md

Recursion-cascade fix release, plus the first installment of the pluginforge.testing namespace and consumer-facing documentation. Driven by real-world feedback from Bibliogon's adoption of v0.7.0 across 12 plugins and ~80 backend endpoints. See docs/design/v0.8.0-improvements-triage-2026-05-20.md for the locked architecture decisions and the wider triage of brief items deferred to v0.9.0+.

Fixed

  • mount_plugin_routes is now idempotent. Long pytest sweeps that re-mount the same plugin set across many TestClient lifespans on a shared FastAPI app no longer accumulate route registrations. The accumulation in v0.7.0 caused RecursionError: maximum recursion depth exceeded in starlette.testclient.TestClient.wait_startup once includes exceeded Python's default recursion limit. Tracking state lives in a module-level WeakKeyDictionary[FastAPI, set[str]] inside pluginforge.fastapi_ext; PluginManager is unchanged and stays framework-agnostic.
  • Permanent regression test tests/test_fastapi_ext_cascade.py::TestRecursionCascadeCanary reproduces the cascade against unfixed code (default 20 plugins x 10 routes x 50 lifespans) and asserts no RecursionError against fixed code. A @pytest.mark.slow full-scale variant (50 x 20 x 100) runs when PLUGINFORGE_CANARY_FULL=1.

Added

  • pluginforge.testing public namespace for consumer-app test wiring:
    • IsolatedPluginManager: context manager that yields a fresh PluginManager backed by a temp config directory and clears the module-level FastAPI mount-tracking on entry and exit. Replaces ad-hoc consumer-side isolation helpers.
    • MockPlugin: fluent builder for synthetic plugin instances (MockPlugin.builder().name(...).target_application(...).routes([...]).build()). Routes synthesize a real FastAPI APIRouter when FastAPI is installed; otherwise get_routes() returns [] silently.
  • py.typed marker (PEP 561). Downstream mypy/pyright users get full type information from PluginForge's annotations. The package has always shipped types; the marker was overdue.
  • Consumer-app integration guide at docs/guides/consumer-integration.md. Covers PluginManager construction, FastAPI lifespan integration, idempotent route mounting, DiscoveryResult in a settings UI, hot reload, health aggregation, and testing with pluginforge.testing. Includes a v0.7.0 -> v0.8.0 migration section.
  • Plugin-author guide at docs/guides/plugin-author.md. Skeleton release: the Single-Router Convention is fully documented (it ties to the new deprecation warning below); other sections carry one-paragraph summaries plus TODO: expand in v0.9.0 markers.

Deprecated

  • Plugins returning multiple routers from get_routes() now emit a DeprecationWarning recommending the Single-Router Convention (one parent router that uses router.include_router(sub) to nest namespace-separated sub-routers). The warning continues in v0.9.0; v0.10.0 may make it an error. One full minor between warn and error minimum.

Internal

  • fastapi and httpx added to dev dependencies so the recursion canary and FastAPI-integration tests run in CI (previously they were skipped when FastAPI was not installed).
  • Pre-existing test_mount_routes URL expectation corrected from /api/plugins/routed/hello (which mount_plugin_routes has never served) to /api/hello (the actual v0.7.0 mount semantics). The test had been silently skipping in v0.7.0 because FastAPI was not a dev dependency.
  • slow pytest marker registered in pyproject.toml for the full-scale canary variant.

Test counts (verified)

  • Before this release: 257 passed, 3 skipped, 96% coverage on pluginforge/.
  • After this release: 283 passed, 1 skipped, 97% coverage. pluginforge/fastapi_ext.py at 100% line coverage. Per .claude/rules/ai-workflow.md "Numeric claims verification", future numbers must be re-verified at the release commit and not carried forward from this changelog entry.

Brief items NOT shipped in this release (deferred per the triage doc)

  • P1.1 lifecycle visibility (event hooks + richer PluginState), P1.2 cross-plugin Service-Registry, P2.1 config schema validation, P3.1 unified hot-reload + ZIP path, P3.2 plugin metrics, P3.3 bidirectional version compatibility matrix, P3.4 Health-Check URL surface, P3.6 FilterReason i18n hook. See the triage doc for the rationale per item.
  • P3.5 (error-recovery during activate()) is closed without code change: the behavior has been in place since v0.6.0 via pluginforge/lifecycle.py:99-108. A broken plugin does NOT bring down sibling plugins; the failure surfaces in DiscoveryResult.errors.

v0.7.0: Application identity gating

19 May 11:19
bfacced

Choose a tag to compare

For full design rationale, see docs/design/v0.7.0-application-identity.md.

Application identity release. Plugins can declare which host application they target; hosts can declare their own identity to validate plugin claims.

Added

  • BasePlugin.target_application: str | None = None attribute. Plugins declare the host application identifier they target (e.g. "bibliogon", "adaptivlearner"). None means "not declared."
  • PluginManager(app_id: str | None = None) constructor parameter. Hosts declare their application identifier. When set, plugin target_application declarations are validated against this value.
  • FilterReason extended with "wrong_application". Plugins whose target_application mismatches the host's app_id are filtered with this reason at activation.
  • ErrorPhase extended with "identity_check". New phase for identity-related diagnostics, sibling to version_check.
  • v0.7.0 deprecation warning path: plugins that have not declared target_application produce a PluginError(phase="identity_check", severity="warning") recorded in DiscoveryResult.errors and logged via logger.warning. Plugin still activates. The warning fires regardless of whether the host has adopted app_id, to nudge plugin authors during the deprecation cycle. Warning stops in v0.8.0 steady state.

Changed

  • DiscoveryResult.errors semantic widening: previously contained only severity="error" entries (every entry implied a filtered plugin). Now also contains severity="warning" entries from the identity deprecation path (entries do not imply filtering). Consumers iterating result.errors without filtering on severity should re-check their assumptions: render with severity == "error" to preserve previous behavior, or render warnings in a separate visual treatment. The type contract (list[PluginError]) is unchanged; only the operational meaning is widened.

Migration notes

  • DiscoveryResult.errors semantic widening (see Changed). New behavior may surprise consumers iterating the list without severity filtering. Adapt by filtering on severity == "error" for the previous error-only behavior, or by computing failure counts as len(states) - len(activated) rather than len(errors).

  • Plugin authors: declare target_application on your BasePlugin subclasses with the host application's identifier (e.g. target_application = "bibliogon"). In v0.7.0, plugins without target_application emit a deprecation warning but still activate. In v0.8.0, plugins without target_application will be filtered with FilterReason.missing_target_application when the host has adopted app_id. The v0.7.0 deprecation warning text, surfaced both in DiscoveryResult.errors[].user_facing_message and in the logger.warning log line:

    Plugin 'X' does not declare target_application. Hosts adopting app_id in v0.8.0 or later will filter this plugin. Authors should add target_application to remain compatible with identity-aware hosts.

  • Host application owners: pass app_id to PluginManager(app_id="my-app") to enable identity gating. Plugins whose target_application matches app_id activate normally; mismatches refuse activation with FilterReason.wrong_application. Without app_id, identity is not validated and behavior is identical to v0.6.x. This mirrors the v0.6.0 min_app_version / app_version pattern explicitly: a plugin's identity claim is enforced only when the host opts in by declaring its own identity. A host can run all of v0.6.x, v0.7.0, and v0.8.0 without ever adopting app_id, and continue to load plugins as it always has. Identity protection is available, not mandatory.

  • Identifier format: free string, lowercase-kebab-case recommended (e.g. bibliogon, acme-knowledge-base). Reverse-domain notation and a central registry are deferred as open questions in the design doc.

v0.6.1: Packaging hygiene

19 May 10:20
5f7fdc1

Choose a tag to compare

Packaging hygiene release. No API changes.

Changed

  • Migrated package metadata from [tool.poetry] to PEP 621 [project] table (name, version, description, readme, license, authors, keywords, classifiers, requires-python, dependencies). Build backend remains poetry-core. [tool.poetry] retains only Poetry-specific concerns: packages and group.dev.dependencies.
  • Package metadata now includes a maintainer contact email (aster.raptis@gmail.com, matching the PyPI account).
  • [project.urls] now declares five entries: Homepage, Repository, Issue Tracker, Documentation, Changelog. Previously only the repository URL was set via [tool.poetry].

Fixed

  • Removed deprecated License :: OSI Approved :: MIT License classifier; license is now declared via PEP 639 [project.license] SPDX expression ("MIT").
  • poetry check is now warning-free. Previously emitted nine deprecation warnings about [tool.poetry.*] keys being replaced by PEP 621 [project.*].

v0.6.0: Plugin Lifecycle

19 May 09:22

Choose a tag to compare

For full design rationale, see docs/design/v0.6.0-plugin-lifecycle.md.

Added

  • pluginforge.state module with new structured types: PluginError, PluginState, DiscoveryResult, DiscoveryDiff, plus the FilterReason and ErrorPhase literal type aliases.
  • PluginManager.rediscover() re-reads entry points after invalidating importlib and importlib.metadata caches, activates newly-discovered plugins, deactivates removed ones, and leaves unchanged ones untouched. Returns a DiscoveryDiff.
  • PluginManager.refresh_config(new_app_config=None) replaces the app-config snapshot and notifies each active plugin via the new on_config_changed(old, new) hook. With no argument, reloads app.yaml from disk.
  • BasePlugin.on_config_changed(old, new) lifecycle hook (default no-op) for plugins that need to react to config changes.
  • BasePlugin.min_app_version attribute. When the host sets app_version on PluginManager, plugins are gated against this minimum using packaging.version.Version.
  • New PluginManager constructor parameters: app_version, api_version_severity, app_version_severity. Defaults preserve existing behaviour (api_version_severity="warning") while making the new min_app_version check error-by-default when app_version is set.
  • pluginforge.utils.config_diff(old, new) convenience helper returning {key: (old, new)} for top-level changed keys.
  • PluginManager.get_last_discovery_result() returns the most recent DiscoveryResult (or None before the first call).
  • FilterReason literal with eight values: not_discovered, not_enabled, disabled, incompatible_api_version, incompatible_app_version, dependency_unmet, pre_activate_rejected, load_failed.
  • ErrorPhase literal with six values: discovery, version_check, dependency_check, pre_activate, activation, config_refresh.
  • packaging (>=21.0) as a direct dependency for Version comparison.

Changed

  • discover_plugins() and register_plugins() now return DiscoveryResult instead of None. Existing callers that ignore the return value continue to work.
  • pre_activate callback failures are now distinguished: returning False produces filter_reason="pre_activate_rejected" with no exception cause; raising produces filter_reason="load_failed" with load_error.phase="pre_activate" and load_error.cause set to the raised exception. Both paths were previously conflated as a single string in get_load_errors().
  • Missing depends_on targets now produce filter_reason="dependency_unmet" with a structured PluginError, instead of only appearing as a flat string in get_load_errors().

Migration notes

  • get_load_errors() continues to return the same dict[str, str] of name to message. It is now sourced internally from DiscoveryResult.errors. New code should prefer DiscoveryResult.errors directly, along with DiscoveryResult.states for richer per-plugin diagnostics including filter reasons. No removal timeline is committed; see the design doc's open question Q4 for current status.
  • Consumers that previously mutated manager._app_config directly should migrate to manager.refresh_config(new_dict). In-place mutation that bypasses this method will not notify plugins via on_config_changed or update each plugin's self.app_config reference.
  • Hosts that want min_app_version enforcement must pass app_version="x.y.z" to PluginManager. Without this argument, plugin min_app_version declarations are ignored (back-compat).
  • Hosts that want api_version mismatches to gate activation (rather than warn) should pass api_version_severity="error".
  • See docs/design/v0.6.0-plugin-lifecycle.md for the full design rationale and open questions.

Removed

  • Non-functional fastapi and alembic extras declarations. These referenced dependencies that were never declared as optional, so pip install pluginforge[fastapi] silently installed nothing. Removed entirely; will be reintroduced properly if and when the integrations ship.

Known Limitations

  • No application identity field; isolation relies on entry-point group separation as a publishing convention. See README "Known Limitations". Scheduled for v0.7.0 design.