Releases: astrapi69/pluginforge
v0.10.0: Consolidation
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, andNonereplace). Whennotify=True(default), fireson_config_changedon active plugins via the same codepath asrefresh_config. Whennotify=False, replaces the snapshot silently - appropriate at startup beforediscover_plugins()when no plugins are active to notify. Pre-existing keys not mentioned in the overlay are preserved (the semantic distinction fromrefresh_config, which replaces the entire snapshot). The internal_deep_mergehelper is module-private and NOT exposed as a public utility; consumers needing custom merge semantics pre-compute their merged dict and userefresh_config(merged_dict, notify=...).PluginManager.refresh_configgains anotify: bool = Truekeyword argument. Settingnotify=Falsereplaces the snapshot without firingon_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 mirroringDiscoveryResult.by_filter_reason. Bibliogon consumers who iterateddiff.states.items()after everyrediscover()to group by filter reason can now use the one-line accessor.FilterReason,ErrorPhase, andconfig_diffare re-exported at the package root viapluginforge.__all__. The v0.6.0 CHANGELOG documented them as public API butpluginforge/__init__.pyonly exposed them via deep imports frompluginforge.stateandpluginforge.utils. Both forms keep working alongside the canonical top-level import:from pluginforge import FilterReason, ErrorPhase, config_diff.tests/test_public_api.pyregression-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_WARNINGtext),docs/guides/plugin-author.md(the deprecation-timeline list), anddocs/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 fromREADME.mdandexamples/README.md. The[fastapi]and[alembic]extras were removed in v0.6.0 (commitaa9a009) 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
twineadded 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 apoetry lockre-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 inpyproject.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.mdaudit 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, structuredDiscoveryResult/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_configreplaces the_app_configassignment hack. Two independent consumers (pluginforge-app-template and AdaptivLearner) had separately developed the samemanager._app_config = merged # type: ignore[attr-defined]pattern to overlay user config on top of the YAML-loaded snapshot. Migrate tomanager.merge_app_config(user_overlay, notify=False)at startup (beforediscover_plugins()) andmanager.merge_app_config(user_overlay)after Settings-UI writes. Thetype: 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_configcallers are unchanged unless they want the new kwarg. Existingpm.refresh_config(new_dict)calls keep their existing notify-active-plugins behavior. Only callers who explicitly passnotify=Falsesee the new path. -
Public-API re-exports are additive.
from pluginforge.state import FilterReason, ErrorPhaseandfrom pluginforge.utils import config_diffkeep working. The new canonical form isfrom 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
Severityto__all__. Consumers comparingerror.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 frompoetry run pytest --collect-only -q | tail -5,poetry run pytest --tb=no -q | tail -3, andpoetry run pytest --cov=pluginforge --cov-report=term | tail -3run 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
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 subsequentdeactivate_plugin/deactivate_all. Cleared (along with the rest of the state entry) only byrediscoverremoval.PluginState.last_config_change: datetime | None. UTC timestamp updated whenrefresh_confignotifies this plugin viaon_config_changedand 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"fordiscover_pluginsandrediscover,"direct_register"forregister_pluginsandregister_plugin.Nonefor plugins named inenabledconfig but never found via entry points (filter_reason="not_discovered").PluginInspectionfrozen dataclass aggregatingname,version,target_application,min_app_version,state(fullPluginState),config,health,hooks,routes,dependencies. Exported frompluginforgepackage.PluginManager.inspect_plugin(name) -> PluginInspection | None. Single-call replacement for the union ofget_plugin/get_last_discovery_result/get_plugin_hooks/plugin.health()/plugin.configaccessors. ReturnsNonefor 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 duringdeactivate_all).callback(plugin_name).PluginManager.on_config_refreshed(callback) -> deregistration closure. Fired per plugin whoseon_config_changedcompleted without raising duringrefresh_config.callback(plugin_name, old_app_config, new_app_config).
Changed
Hard-filter transition (deferred from v0.8.0):
FilterReasongains"missing_target_application". Plugins that have not declaredtarget_applicationare now filtered with this reason at activation when the host has declaredapp_id. The v0.7.0 deprecation cycle has ended.PluginManager._check_identityreturnsseverity="error"instead ofseverity="warning"for the host-has-app_id+ plugin-missing-target_applicationrow (matrix row 3 of the v0.7.0 design doc). Plugin does not activate. Filter reason:"missing_target_application".PluginManager._check_identityno 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_idsee no behavior change.
Event-callback contract in refresh_config:
refresh_confignow updatesPluginState.last_config_changeon the unified state tracker for each plugin whoseon_config_changedcompleted without raising. Plugins whose hook raised are skipped for both the timestamp update and the newon_config_refreshedevent firing.
Migration notes
-
Lifecycle-visibility fields are additive; no migration required. Existing consumers reading
PluginStatesee the new fields with defaultNonevalues. -
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_applicationon itsBasePluginsubclass, it will now be filtered when loaded into a host that has adoptedapp_id. Declaretarget_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_idonPluginManager, you see no behavior change in v0.9.0. The v0.6.0min_app_version/app_versionpermissive-semantics pattern still holds; a host can run v0.6.x through v0.9.0 without ever adoptingapp_idand continue to load plugins as it always has. -
v0.7.0 deprecation warning is gone. Code that was filtering
result.errorsbyseverity == "warning"andphase == "identity_check"will see no such entries in v0.9.0.
v0.8.0: Stability and tooling
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_routesis now idempotent. Long pytest sweeps that re-mount the same plugin set across manyTestClientlifespans on a shared FastAPI app no longer accumulate route registrations. The accumulation in v0.7.0 causedRecursionError: maximum recursion depth exceededinstarlette.testclient.TestClient.wait_startuponce includes exceeded Python's default recursion limit. Tracking state lives in a module-levelWeakKeyDictionary[FastAPI, set[str]]insidepluginforge.fastapi_ext;PluginManageris unchanged and stays framework-agnostic.- Permanent regression test
tests/test_fastapi_ext_cascade.py::TestRecursionCascadeCanaryreproduces the cascade against unfixed code (default 20 plugins x 10 routes x 50 lifespans) and asserts noRecursionErroragainst fixed code. A@pytest.mark.slowfull-scale variant (50 x 20 x 100) runs whenPLUGINFORGE_CANARY_FULL=1.
Added
pluginforge.testingpublic namespace for consumer-app test wiring:IsolatedPluginManager: context manager that yields a freshPluginManagerbacked 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 FastAPIAPIRouterwhen FastAPI is installed; otherwiseget_routes()returns[]silently.
py.typedmarker (PEP 561). Downstreammypy/pyrightusers 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
PluginManagerconstruction, FastAPI lifespan integration, idempotent route mounting,DiscoveryResultin a settings UI, hot reload, health aggregation, and testing withpluginforge.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.0markers.
Deprecated
- Plugins returning multiple routers from
get_routes()now emit aDeprecationWarningrecommending the Single-Router Convention (one parent router that usesrouter.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
fastapiandhttpxadded 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_routesURL expectation corrected from/api/plugins/routed/hello(whichmount_plugin_routeshas 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. slowpytest marker registered inpyproject.tomlfor 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.pyat 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 viapluginforge/lifecycle.py:99-108. A broken plugin does NOT bring down sibling plugins; the failure surfaces inDiscoveryResult.errors.
v0.7.0: Application identity gating
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 = Noneattribute. Plugins declare the host application identifier they target (e.g."bibliogon","adaptivlearner").Nonemeans "not declared."PluginManager(app_id: str | None = None)constructor parameter. Hosts declare their application identifier. When set, plugintarget_applicationdeclarations are validated against this value.FilterReasonextended with"wrong_application". Plugins whosetarget_applicationmismatches the host'sapp_idare filtered with this reason at activation.ErrorPhaseextended with"identity_check". New phase for identity-related diagnostics, sibling toversion_check.- v0.7.0 deprecation warning path: plugins that have not declared
target_applicationproduce aPluginError(phase="identity_check", severity="warning")recorded inDiscoveryResult.errorsand logged vialogger.warning. Plugin still activates. The warning fires regardless of whether the host has adoptedapp_id, to nudge plugin authors during the deprecation cycle. Warning stops in v0.8.0 steady state.
Changed
DiscoveryResult.errorssemantic widening: previously contained onlyseverity="error"entries (every entry implied a filtered plugin). Now also containsseverity="warning"entries from the identity deprecation path (entries do not imply filtering). Consumers iteratingresult.errorswithout filtering on severity should re-check their assumptions: render withseverity == "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.errorssemantic widening (see Changed). New behavior may surprise consumers iterating the list without severity filtering. Adapt by filtering onseverity == "error"for the previous error-only behavior, or by computing failure counts aslen(states) - len(activated)rather thanlen(errors). -
Plugin authors: declare
target_applicationon yourBasePluginsubclasses with the host application's identifier (e.g.target_application = "bibliogon"). In v0.7.0, plugins withouttarget_applicationemit a deprecation warning but still activate. In v0.8.0, plugins withouttarget_applicationwill be filtered withFilterReason.missing_target_applicationwhen the host has adoptedapp_id. The v0.7.0 deprecation warning text, surfaced both inDiscoveryResult.errors[].user_facing_messageand in thelogger.warninglog 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_idtoPluginManager(app_id="my-app")to enable identity gating. Plugins whosetarget_applicationmatchesapp_idactivate normally; mismatches refuse activation withFilterReason.wrong_application. Withoutapp_id, identity is not validated and behavior is identical to v0.6.x. This mirrors the v0.6.0min_app_version/app_versionpattern 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 adoptingapp_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
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 remainspoetry-core.[tool.poetry]retains only Poetry-specific concerns:packagesandgroup.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 therepositoryURL was set via[tool.poetry].
Fixed
- Removed deprecated
License :: OSI Approved :: MIT Licenseclassifier; license is now declared via PEP 639[project.license]SPDX expression ("MIT"). poetry checkis now warning-free. Previously emitted nine deprecation warnings about[tool.poetry.*]keys being replaced by PEP 621[project.*].
v0.6.0: Plugin Lifecycle
For full design rationale, see docs/design/v0.6.0-plugin-lifecycle.md.
Added
pluginforge.statemodule with new structured types:PluginError,PluginState,DiscoveryResult,DiscoveryDiff, plus theFilterReasonandErrorPhaseliteral type aliases.PluginManager.rediscover()re-reads entry points after invalidatingimportlibandimportlib.metadatacaches, activates newly-discovered plugins, deactivates removed ones, and leaves unchanged ones untouched. Returns aDiscoveryDiff.PluginManager.refresh_config(new_app_config=None)replaces the app-config snapshot and notifies each active plugin via the newon_config_changed(old, new)hook. With no argument, reloadsapp.yamlfrom disk.BasePlugin.on_config_changed(old, new)lifecycle hook (default no-op) for plugins that need to react to config changes.BasePlugin.min_app_versionattribute. When the host setsapp_versiononPluginManager, plugins are gated against this minimum usingpackaging.version.Version.- New
PluginManagerconstructor parameters:app_version,api_version_severity,app_version_severity. Defaults preserve existing behaviour (api_version_severity="warning") while making the newmin_app_versioncheck error-by-default whenapp_versionis 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 recentDiscoveryResult(orNonebefore the first call).FilterReasonliteral with eight values:not_discovered,not_enabled,disabled,incompatible_api_version,incompatible_app_version,dependency_unmet,pre_activate_rejected,load_failed.ErrorPhaseliteral with six values:discovery,version_check,dependency_check,pre_activate,activation,config_refresh.packaging(>=21.0) as a direct dependency forVersioncomparison.
Changed
discover_plugins()andregister_plugins()now returnDiscoveryResultinstead ofNone. Existing callers that ignore the return value continue to work.pre_activatecallback failures are now distinguished: returningFalseproducesfilter_reason="pre_activate_rejected"with no exception cause; raising producesfilter_reason="load_failed"withload_error.phase="pre_activate"andload_error.causeset to the raised exception. Both paths were previously conflated as a single string inget_load_errors().- Missing
depends_ontargets now producefilter_reason="dependency_unmet"with a structuredPluginError, instead of only appearing as a flat string inget_load_errors().
Migration notes
get_load_errors()continues to return the samedict[str, str]of name to message. It is now sourced internally fromDiscoveryResult.errors. New code should preferDiscoveryResult.errorsdirectly, along withDiscoveryResult.statesfor 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_configdirectly should migrate tomanager.refresh_config(new_dict). In-place mutation that bypasses this method will not notify plugins viaon_config_changedor update each plugin'sself.app_configreference. - Hosts that want
min_app_versionenforcement must passapp_version="x.y.z"toPluginManager. Without this argument, pluginmin_app_versiondeclarations are ignored (back-compat). - Hosts that want
api_versionmismatches to gate activation (rather than warn) should passapi_version_severity="error". - See docs/design/v0.6.0-plugin-lifecycle.md for the full design rationale and open questions.
Removed
- Non-functional
fastapiandalembicextras declarations. These referenced dependencies that were never declared as optional, sopip 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.