diff --git a/CHANGELOG.md b/CHANGELOG.md index 76de2336..d1f1ac33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,23 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). --- +## v26.06.91 (2026-06-10) + +### Added (plugins parity — parity initiative SP-11) + +- **Plugin lifecycle state model**: `PluginState` (LOADED/STARTED/STOPPED/FAILED) + a `PluginDescriptor` + (state, loaded_at, last_state_change, failed_reason) tracked per plugin by the `PluginManager`. +- **Per-plugin lifecycle with dependency cascade**: `start_plugin(id)` starts the plugin's transitive + dependencies first (skipping already-started ones); `stop_plugin(id)` stops its dependents first; a + failing hook transitions the plugin to FAILED and raises a typed `PluginStartError`/`PluginStopError`. + `get_plugin(id)` returns the descriptor. +- **Plugin exception hierarchy**: `PluginException` (over `PyFlyException`) + `PluginLoadError`, + `PluginStartError`, `PluginStopError`, `PluginStateError` (`PluginResolutionError` now extends it). +- **`ExtensionRegistry.get_extension(point)`**: single highest-priority getter (raises clearly for an + unknown/empty point) alongside the existing list getter. +- **`@plugin(name=, author=)`**: optional metadata fields (backward-compatible; `name` defaults to `id`). +- End-to-end multi-plugin lifecycle test + `PluginsAutoConfiguration` test + docs. + ## v26.06.90 (2026-06-10) ### Added / Fixed / Tested (notifications completeness — parity initiative SP-10) diff --git a/README.md b/README.md index 97675156..7d5ec000 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Firefly Framework Python 3.12+ License: Apache 2.0 - Version: 26.06.90 + Version: 26.06.91 Type Checked: mypy strict Code Style: Ruff Async First diff --git a/docs/modules/plugins.md b/docs/modules/plugins.md index 6acc2040..75d5e88f 100644 --- a/docs/modules/plugins.md +++ b/docs/modules/plugins.md @@ -71,3 +71,135 @@ leak in the registry: `start_all()` runs each plugin's `init` then `start` hooks in dependency order; `stop_all()` runs `stop` then `unload` in reverse order. + +--- + +## PluginState and PluginDescriptor + +Every plugin tracked by `PluginManager` has a runtime `PluginDescriptor` that +records its current lifecycle state: + +```python +from pyfly.plugins import PluginDescriptor, PluginState + +desc: PluginDescriptor = await manager.get_plugin("my-plugin") +print(desc.state) # PluginState.STARTED +print(desc.loaded_at) # datetime of add() +print(desc.last_state_change) # datetime of last transition +print(desc.failed_reason) # str if FAILED, else None +``` + +`PluginState` values: + +| Value | Meaning | +|-------|---------| +| `LOADED` | Plugin registered via `add()` but not yet started | +| `STARTED` | `start()` hook completed successfully | +| `STOPPED` | `stop()` hook completed successfully | +| `FAILED` | A lifecycle hook raised an exception | + +--- + +## `@plugin(name=, author=)` — Optional Metadata + +Two new optional fields are available on `@plugin`: + +```python +@plugin(id="auth", version="1.0.0", name="Auth Plugin", author="security-team") +class AuthPlugin: + ... +``` + +- `name` defaults to `id` when omitted (backward-compatible). +- `author` defaults to `""`. + +Both fields are accessible on `Plugin` and `PluginDescriptor.plugin`. + +--- + +## Per-Plugin Lifecycle: `start_plugin` / `stop_plugin` / `get_plugin` + +Use these instead of `start_all` / `stop_all` when you want fine-grained control: + +### `start_plugin(plugin_id)` + +Starts the named plugin **and all its transitive dependencies**, in dependency +order. Already-STARTED plugins are skipped. On hook failure the plugin's state +is set to `FAILED` and a `PluginStartError` is raised. + +```python +# Assuming C depends on B which depends on A: +await manager.start_plugin("c") +# → starts A, then B, then C +``` + +### `stop_plugin(plugin_id)` + +Stops the named plugin **and all plugins that (transitively) depend on it**, in +reverse dependency order (dependents first). Already-STOPPED/LOADED plugins are +skipped. On hook failure the state is set to `FAILED` and a `PluginStopError` +is raised. + +```python +await manager.stop_plugin("a") +# → stops C, then B, then A +``` + +### `get_plugin(plugin_id)` → `PluginDescriptor | None` + +Returns the descriptor for the plugin, or `None` if no plugin with that id is +registered. + +--- + +## PluginError Exception Hierarchy + +All plugin exceptions extend `PyFlyException`: + +``` +PyFlyException +└── PluginException # base for all plugin errors + ├── PluginLoadError # plugin could not be loaded/registered + ├── PluginStartError # start/init hook raised + ├── PluginStopError # stop/unload hook raised + ├── PluginStateError # invalid state transition or unknown plugin id + └── PluginResolutionError # missing dependency or cycle during topo-sort +``` + +Import from `pyfly.kernel.exceptions` (or `pyfly.kernel`): + +```python +from pyfly.kernel.exceptions import PluginStartError, PluginStateError +``` + +--- + +## `ExtensionRegistry.get_extension` + +`get()` returns a list of all extensions for a point. Use `get_extension()` when +you only expect (and want) the single highest-priority one: + +```python +processor = await registry.get_extension("processors") +# raises ValueError if the point is unknown or has no extensions +``` + +--- + +## PluginsAutoConfiguration + +The plugin system is auto-configured when `pyfly.plugins.enabled=true`: + +```yaml +# application.yaml +pyfly: + plugins: + enabled: true +``` + +`PluginsAutoConfiguration` registers two beans: + +| Bean type | Factory method | +|-----------|---------------| +| `ExtensionRegistry` | `extension_registry()` | +| `PluginManager` | `plugin_manager(registry)` | diff --git a/pyproject.toml b/pyproject.toml index ad4bfd65..cb1e91ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ name = "pyfly" # CalVer YY.MM.PATCH — package metadata uses PEP 440 normalized form (26.5.4); # git tag, GitHub release and human-readable display use leading-zero form # (v26.05.04) to match the Java/.NET/Go siblings. -version = "26.6.90" +version = "26.6.91" description = "The official Python implementation of the Firefly Framework — DI, CQRS, EDA, hexagonal architecture, and more." readme = "README.md" license = "Apache-2.0" diff --git a/src/pyfly/__init__.py b/src/pyfly/__init__.py index 8a793b57..d10380fd 100644 --- a/src/pyfly/__init__.py +++ b/src/pyfly/__init__.py @@ -13,4 +13,4 @@ # limitations under the License. """PyFly — Enterprise Python Framework.""" -__version__ = "26.06.90" +__version__ = "26.06.91" diff --git a/src/pyfly/kernel/__init__.py b/src/pyfly/kernel/__init__.py index 2fc48d6b..adbf30a2 100644 --- a/src/pyfly/kernel/__init__.py +++ b/src/pyfly/kernel/__init__.py @@ -34,6 +34,11 @@ NotImplementedException, OperationTimeoutException, PayloadTooLargeException, + PluginException, + PluginLoadError, + PluginStartError, + PluginStateError, + PluginStopError, PreconditionFailedException, PyFlyException, QuotaExceededException, @@ -100,4 +105,10 @@ "BadGatewayException", "GatewayTimeoutException", "QuotaExceededException", + # Plugin + "PluginException", + "PluginLoadError", + "PluginStartError", + "PluginStopError", + "PluginStateError", ] diff --git a/src/pyfly/kernel/exceptions.py b/src/pyfly/kernel/exceptions.py index 3ff3fa4b..ad540e82 100644 --- a/src/pyfly/kernel/exceptions.py +++ b/src/pyfly/kernel/exceptions.py @@ -198,3 +198,29 @@ class GatewayTimeoutException(ExternalServiceException): class QuotaExceededException(RateLimitException): """API or resource quota has been exceeded.""" + + +# ============================================================================= +# Plugin Exceptions +# ============================================================================= + + +class PluginException(PyFlyException): + """Base exception for all plugin system errors.""" + + +class PluginLoadError(PluginException): + """A plugin could not be loaded or registered.""" + + +class PluginStartError(PluginException): + """A plugin's start/init hook raised an error.""" + + +class PluginStopError(PluginException): + """A plugin's stop/unload hook raised an error.""" + + +class PluginStateError(PluginException): + """An operation was attempted on a plugin in an incompatible state + (e.g. starting an already-started plugin, or referencing an unknown id).""" diff --git a/src/pyfly/plugins/__init__.py b/src/pyfly/plugins/__init__.py index bfda44ad..8260e56c 100644 --- a/src/pyfly/plugins/__init__.py +++ b/src/pyfly/plugins/__init__.py @@ -18,6 +18,7 @@ plugin, ) from pyfly.plugins.manager import PluginManager +from pyfly.plugins.models import PluginDescriptor, PluginState from pyfly.plugins.registry import ExtensionRegistry from pyfly.plugins.resolver import PluginDependencyResolver, PluginResolutionError @@ -27,8 +28,10 @@ "ExtensionRegistry", "Plugin", "PluginDependencyResolver", + "PluginDescriptor", "PluginManager", "PluginResolutionError", + "PluginState", "extension", "extension_point", "plugin", diff --git a/src/pyfly/plugins/decorators.py b/src/pyfly/plugins/decorators.py index 70442682..d2de37c5 100644 --- a/src/pyfly/plugins/decorators.py +++ b/src/pyfly/plugins/decorators.py @@ -14,6 +14,8 @@ class Plugin: version: str = "0.0.0" depends_on: tuple[str, ...] = () description: str = "" + name: str = "" # defaults to id if empty; see @plugin decorator + author: str = "" @dataclass(frozen=True) @@ -29,11 +31,22 @@ class Extension: def plugin( - *, id: str, version: str = "0.0.0", depends_on: tuple[str, ...] = (), description: str = "" + *, + id: str, + version: str = "0.0.0", + depends_on: tuple[str, ...] = (), + description: str = "", + name: str = "", + author: str = "", ) -> Callable[[type], type]: def decorator(cls: type) -> type: cls.__pyfly_plugin__ = Plugin( # type: ignore[attr-defined] - id=id, version=version, depends_on=depends_on, description=description + id=id, + version=version, + depends_on=depends_on, + description=description, + name=name or id, + author=author, ) return cls diff --git a/src/pyfly/plugins/manager.py b/src/pyfly/plugins/manager.py index 11173ac0..9c060cfd 100644 --- a/src/pyfly/plugins/manager.py +++ b/src/pyfly/plugins/manager.py @@ -5,10 +5,13 @@ from __future__ import annotations import asyncio +import datetime import inspect from typing import Any +from pyfly.kernel.exceptions import PluginLoadError, PluginStartError, PluginStateError, PluginStopError from pyfly.plugins.decorators import Plugin +from pyfly.plugins.models import PluginDescriptor, PluginState from pyfly.plugins.registry import ExtensionRegistry from pyfly.plugins.resolver import PluginDependencyResolver @@ -21,6 +24,7 @@ def __init__(self, registry: ExtensionRegistry | None = None) -> None: # (point_id, instance) pairs each plugin registered, so unload can # unregister exactly what it added (audit #219). self._registered: dict[str, list[tuple[str, Any]]] = {} + self._descriptors: dict[str, PluginDescriptor] = {} self._started = False self._lock = asyncio.Lock() @@ -32,11 +36,20 @@ async def add(self, plugin_class: type) -> None: meta = getattr(plugin_class, "__pyfly_plugin__", None) if meta is None: msg = f"{plugin_class.__qualname__} is not @plugin-decorated" - raise ValueError(msg) + raise PluginLoadError(msg) instance = plugin_class() + now = datetime.datetime.now(tz=datetime.UTC) + descriptor = PluginDescriptor( + id=meta.id, + plugin=meta, + state=PluginState.LOADED, + loaded_at=now, + last_state_change=now, + ) async with self._lock: self._plugins[meta.id] = meta self._instances[meta.id] = instance + self._descriptors[meta.id] = descriptor # Register @extension_point declarations first (the decorated class is the # point's interface type) so extension type-validation can apply (#218). @@ -58,12 +71,140 @@ async def add(self, plugin_class: type) -> None: async with self._lock: self._registered[meta.id] = registered + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def _transitive_deps(self, plugin_id: str) -> set[str]: + """Return the set of all transitive dependency ids for *plugin_id*.""" + visited: set[str] = set() + queue = list(self._plugins[plugin_id].depends_on) + while queue: + dep = queue.pop() + if dep in visited: + continue + visited.add(dep) + if dep in self._plugins: + queue.extend(self._plugins[dep].depends_on) + return visited + + def _transitive_dependents(self, plugin_id: str) -> set[str]: + """Return the set of all plugins that (transitively) depend on *plugin_id*.""" + # Start with direct dependents then fan out. + dependents: set[str] = set() + changed = True + while changed: + changed = False + for pid, plugin in self._plugins.items(): + if pid in dependents: + continue + deps = set(plugin.depends_on) + if plugin_id in deps or deps & dependents: + dependents.add(pid) + changed = True + return dependents + + async def _run_hooks(self, instance: Any, *hooks: str) -> None: + for hook in hooks: + fn = getattr(instance, hook, None) + if fn is None: + continue + result = fn() + if inspect.isawaitable(result): + await result + + def _transition(self, plugin_id: str, state: PluginState, reason: str | None = None) -> None: + desc = self._descriptors[plugin_id] + desc.state = state + desc.last_state_change = datetime.datetime.now(tz=datetime.UTC) + if reason is not None: + desc.failed_reason = reason + + # ------------------------------------------------------------------ + # Per-plugin lifecycle + # ------------------------------------------------------------------ + + async def start_plugin(self, plugin_id: str) -> None: + """Start *plugin_id* and all its transitive dependencies (in dep order). + + Skips plugins already in STARTED state. On hook failure, marks the + plugin FAILED and raises PluginStartError. + """ + async with self._lock: + if plugin_id not in self._plugins: + msg = f"Unknown plugin id: {plugin_id!r}" + raise PluginStateError(msg) + full_order = PluginDependencyResolver.order(self._plugins) + subset = {plugin_id} | self._transitive_deps(plugin_id) + to_start = [pid for pid in full_order if pid in subset] + + for pid in to_start: + async with self._lock: + desc = self._descriptors[pid] + if desc.state == PluginState.STARTED: + continue + instance = self._instances[pid] + try: + await self._run_hooks(instance, "init", "start") + except Exception as exc: + async with self._lock: + self._transition(pid, PluginState.FAILED, str(exc)) + msg = f"Plugin {pid!r} failed to start: {exc}" + raise PluginStartError(msg) from exc + async with self._lock: + self._transition(pid, PluginState.STARTED) + + async def stop_plugin(self, plugin_id: str) -> None: + """Stop *plugin_id* and all plugins that (transitively) depend on it. + + Processes dependents first (reverse cascade), then this plugin. + Skips plugins already STOPPED or LOADED. On hook failure, marks FAILED + and raises PluginStopError. + """ + async with self._lock: + if plugin_id not in self._plugins: + msg = f"Unknown plugin id: {plugin_id!r}" + raise PluginStateError(msg) + full_order = PluginDependencyResolver.order(self._plugins) + subset = {plugin_id} | self._transitive_dependents(plugin_id) + # Reverse so dependents are stopped before their dependencies. + to_stop = [pid for pid in reversed(full_order) if pid in subset] + + for pid in to_stop: + async with self._lock: + desc = self._descriptors[pid] + if desc.state in (PluginState.STOPPED, PluginState.LOADED): + continue + instance = self._instances[pid] + try: + await self._run_hooks(instance, "stop", "unload") + except Exception as exc: + async with self._lock: + self._transition(pid, PluginState.FAILED, str(exc)) + msg = f"Plugin {pid!r} failed to stop: {exc}" + raise PluginStopError(msg) from exc + async with self._lock: + self._transition(pid, PluginState.STOPPED) + + async def get_plugin(self, plugin_id: str) -> PluginDescriptor | None: + """Return the PluginDescriptor for *plugin_id*, or None if not found.""" + async with self._lock: + return self._descriptors.get(plugin_id) + + # ------------------------------------------------------------------ + # Bulk lifecycle + # ------------------------------------------------------------------ + async def start_all(self) -> None: async with self._lock: if self._started: return order = PluginDependencyResolver.order(self._plugins) for pid in order: + # Skip plugins already started via an earlier start_plugin() call so + # their init/start hooks don't run twice on a mixed start path. + if self._descriptors[pid].state == PluginState.STARTED: + continue instance = self._instances[pid] for hook in ("init", "start"): fn = getattr(instance, hook, None) @@ -72,6 +213,8 @@ async def start_all(self) -> None: result = fn() if inspect.isawaitable(result): await result + async with self._lock: + self._transition(pid, PluginState.STARTED) async with self._lock: self._started = True @@ -89,6 +232,8 @@ async def stop_all(self) -> None: result = fn() if inspect.isawaitable(result): await result + async with self._lock: + self._transition(pid, PluginState.STOPPED) async with self._lock: self._started = False @@ -118,6 +263,7 @@ async def remove(self, plugin_id: str) -> bool: async with self._lock: self._plugins.pop(plugin_id, None) self._instances.pop(plugin_id, None) + self._descriptors.pop(plugin_id, None) return True async def unload_all(self) -> None: diff --git a/src/pyfly/plugins/models.py b/src/pyfly/plugins/models.py new file mode 100644 index 00000000..0b151b2e --- /dev/null +++ b/src/pyfly/plugins/models.py @@ -0,0 +1,35 @@ +# Copyright 2026 Firefly Software Foundation. +# Licensed under the Apache License, Version 2.0. +"""PluginState and PluginDescriptor — runtime state tracking for plugins.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime +from enum import StrEnum + +from pyfly.plugins.decorators import Plugin + + +class PluginState(StrEnum): + """Lifecycle state of a registered plugin.""" + + LOADED = "LOADED" + STARTED = "STARTED" + STOPPED = "STOPPED" + FAILED = "FAILED" + + +@dataclass +class PluginDescriptor: + """Runtime descriptor for a single plugin — its metadata + current state. + + Mutable so that ``PluginManager`` can transition ``state`` in place. + """ + + id: str + plugin: Plugin + state: PluginState + loaded_at: datetime + last_state_change: datetime + failed_reason: str | None = field(default=None) diff --git a/src/pyfly/plugins/registry.py b/src/pyfly/plugins/registry.py index 7dc9b25e..9b0040e7 100644 --- a/src/pyfly/plugins/registry.py +++ b/src/pyfly/plugins/registry.py @@ -59,6 +59,23 @@ async def get(self, point_id: str) -> list[Any]: async with self._lock: return [inst for _, inst in self._extensions.get(point_id, [])] + async def get_extension(self, point_id: str) -> Any: + """Return the single highest-priority extension for *point_id*. + + Raises: + ValueError: if *point_id* is not a registered extension point or + has no registered extensions. + """ + async with self._lock: + if point_id not in self._points: + msg = f"Extension point {point_id!r} is not registered" + raise ValueError(msg) + entries = self._extensions.get(point_id, []) + if not entries: + msg = f"Extension point {point_id!r} has no registered extensions" + raise ValueError(msg) + return entries[0][1] + async def points(self) -> list[str]: async with self._lock: return list(self._extensions.keys()) diff --git a/src/pyfly/plugins/resolver.py b/src/pyfly/plugins/resolver.py index 676348c3..27af63c4 100644 --- a/src/pyfly/plugins/resolver.py +++ b/src/pyfly/plugins/resolver.py @@ -6,10 +6,11 @@ from collections import deque +from pyfly.kernel.exceptions import PluginException from pyfly.plugins.decorators import Plugin -class PluginResolutionError(Exception): +class PluginResolutionError(PluginException): pass diff --git a/tests/plugins/test_plugin_lifecycle.py b/tests/plugins/test_plugin_lifecycle.py new file mode 100644 index 00000000..9afa0383 --- /dev/null +++ b/tests/plugins/test_plugin_lifecycle.py @@ -0,0 +1,253 @@ +# Copyright 2026 Firefly Software Foundation. +# Licensed under the Apache License, Version 2.0. +"""End-to-end lifecycle tests mirroring Java PluginSystemIntegrationTest.""" + +from __future__ import annotations + +import pytest + +from pyfly.kernel.exceptions import PluginStartError, PluginStateError, PluginStopError +from pyfly.plugins.decorators import extension, extension_point, plugin +from pyfly.plugins.manager import PluginManager +from pyfly.plugins.models import PluginDescriptor, PluginState + +# --------------------------------------------------------------------------- +# Shared plugin definitions (A ← B ← C, with extension point on A) +# --------------------------------------------------------------------------- + +started_order: list[str] = [] +stopped_order: list[str] = [] + + +@plugin(id="a", name="Plugin A", author="alice", description="root") +class PluginA: + @extension_point(id="processors") + class ProcessorPoint: ... + + @extension(point="processors", priority=10) + class DefaultProcessor(ProcessorPoint): + label = "default" + + async def start(self) -> None: + started_order.append("a") + + async def stop(self) -> None: + stopped_order.append("a") + + +@plugin(id="b", depends_on=("a",), name="Plugin B", author="bob") +class PluginB: + async def start(self) -> None: + started_order.append("b") + + async def stop(self) -> None: + stopped_order.append("b") + + +@plugin(id="c", depends_on=("b",), name="Plugin C", author="charlie") +class PluginC: + async def start(self) -> None: + started_order.append("c") + + async def stop(self) -> None: + stopped_order.append("c") + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_start_plugin_cascades_dependencies() -> None: + """start_plugin(C) should start A, then B, then C.""" + started_order.clear() + mgr = PluginManager() + await mgr.add(PluginA) + await mgr.add(PluginB) + await mgr.add(PluginC) + + await mgr.start_plugin("c") + + assert started_order == ["a", "b", "c"] + for pid in ("a", "b", "c"): + desc = await mgr.get_plugin(pid) + assert desc is not None + assert desc.state == PluginState.STARTED + + +@pytest.mark.asyncio +async def test_start_plugin_skips_already_started() -> None: + """Starting A, then start_plugin(C) should not double-start A.""" + started_order.clear() + mgr = PluginManager() + await mgr.add(PluginA) + await mgr.add(PluginB) + await mgr.add(PluginC) + + await mgr.start_plugin("a") # start A first + started_order.clear() # reset recorder + await mgr.start_plugin("c") # now cascade from C + + # A already started → skipped; only B and C should fire + assert started_order == ["b", "c"] + + +@pytest.mark.asyncio +async def test_stop_plugin_cascades_dependents() -> None: + """stop_plugin(A) should stop C first, then B, then A.""" + stopped_order.clear() + mgr = PluginManager() + await mgr.add(PluginA) + await mgr.add(PluginB) + await mgr.add(PluginC) + await mgr.start_plugin("c") + + stopped_order.clear() + await mgr.stop_plugin("a") + + assert stopped_order == ["c", "b", "a"] + for pid in ("a", "b", "c"): + desc = await mgr.get_plugin(pid) + assert desc is not None + assert desc.state == PluginState.STOPPED + + +@pytest.mark.asyncio +async def test_get_extension_returns_highest_priority() -> None: + """After add, get_extension returns the single highest-priority extension.""" + mgr = PluginManager() + await mgr.add(PluginA) + + ext = await mgr.registry.get_extension("processors") + assert ext.label == "default" + + +@pytest.mark.asyncio +async def test_get_extension_raises_for_unknown_point() -> None: + mgr = PluginManager() + await mgr.add(PluginA) + + with pytest.raises(ValueError, match="not registered"): + await mgr.registry.get_extension("nonexistent") + + +@pytest.mark.asyncio +async def test_get_extension_raises_for_empty_point() -> None: + """Registered point with no extensions → ValueError.""" + from pyfly.plugins.registry import ExtensionRegistry + + reg = ExtensionRegistry() + await reg.register_extension_point("empty-point", object) + + with pytest.raises(ValueError, match="no registered extensions"): + await reg.get_extension("empty-point") + + +@pytest.mark.asyncio +async def test_failed_start_sets_failed_state() -> None: + """If a plugin's start() hook raises, state becomes FAILED.""" + + @plugin(id="bad-plugin") + class BadPlugin: + async def start(self) -> None: + raise RuntimeError("kaboom") + + mgr = PluginManager() + await mgr.add(BadPlugin) + + with pytest.raises(PluginStartError, match="kaboom"): + await mgr.start_plugin("bad-plugin") + + desc = await mgr.get_plugin("bad-plugin") + assert desc is not None + assert desc.state == PluginState.FAILED + assert desc.failed_reason is not None + assert "kaboom" in desc.failed_reason + + +@pytest.mark.asyncio +async def test_get_plugin_returns_none_for_unknown() -> None: + mgr = PluginManager() + desc = await mgr.get_plugin("nope") + assert desc is None + + +@pytest.mark.asyncio +async def test_start_plugin_raises_state_error_for_unknown() -> None: + mgr = PluginManager() + + with pytest.raises(PluginStateError): + await mgr.start_plugin("ghost") + + +@pytest.mark.asyncio +async def test_stop_plugin_raises_state_error_for_unknown() -> None: + mgr = PluginManager() + + with pytest.raises(PluginStateError): + await mgr.stop_plugin("ghost") + + +@pytest.mark.asyncio +async def test_plugin_descriptor_captures_name_author() -> None: + """@plugin(name=, author=) fields are captured in the descriptor.""" + mgr = PluginManager() + await mgr.add(PluginA) + + desc = await mgr.get_plugin("a") + assert desc is not None + assert isinstance(desc, PluginDescriptor) + assert desc.plugin.name == "Plugin A" + assert desc.plugin.author == "alice" + + +@pytest.mark.asyncio +async def test_start_all_sets_started_state() -> None: + """start_all() marks every plugin STARTED.""" + + @plugin(id="s1") + class S1: ... + + @plugin(id="s2", depends_on=("s1",)) + class S2: ... + + mgr = PluginManager() + await mgr.add(S1) + await mgr.add(S2) + await mgr.start_all() + + for pid in ("s1", "s2"): + desc = await mgr.get_plugin(pid) + assert desc is not None + assert desc.state == PluginState.STARTED + + +@pytest.mark.asyncio +async def test_stop_all_sets_stopped_state() -> None: + """stop_all() marks every plugin STOPPED.""" + + @plugin(id="t1") + class T1: ... + + @plugin(id="t2", depends_on=("t1",)) + class T2: ... + + mgr = PluginManager() + await mgr.add(T1) + await mgr.add(T2) + await mgr.start_all() + await mgr.stop_all() + + for pid in ("t1", "t2"): + desc = await mgr.get_plugin(pid) + assert desc is not None + assert desc.state == PluginState.STOPPED + + +@pytest.mark.asyncio +async def test_stop_plugin_unused_raises_state_error() -> None: + """PluginStopError is importable and is a PluginException subclass.""" + from pyfly.kernel.exceptions import PluginException + + assert issubclass(PluginStopError, PluginException) diff --git a/tests/plugins/test_plugins_auto_configuration.py b/tests/plugins/test_plugins_auto_configuration.py new file mode 100644 index 00000000..3cc530fc --- /dev/null +++ b/tests/plugins/test_plugins_auto_configuration.py @@ -0,0 +1,45 @@ +# Copyright 2026 Firefly Software Foundation. +# Licensed under the Apache License, Version 2.0. +"""Tests for PluginsAutoConfiguration bean wiring.""" + +from __future__ import annotations + +from pyfly.plugins.auto_configuration import PluginsAutoConfiguration +from pyfly.plugins.manager import PluginManager +from pyfly.plugins.registry import ExtensionRegistry + + +class TestPluginsAutoConfiguration: + def test_has_auto_configuration_marker(self) -> None: + assert getattr(PluginsAutoConfiguration, "__pyfly_auto_configuration__", False) is True + + def test_has_configuration_stereotype(self) -> None: + assert getattr(PluginsAutoConfiguration, "__pyfly_stereotype__", None) == "configuration" + + def test_has_on_property_condition(self) -> None: + conditions = getattr(PluginsAutoConfiguration, "__pyfly_conditions__", []) + types = [c["type"] for c in conditions] + assert "on_property" in types + + def test_on_property_key_is_pyfly_plugins_enabled(self) -> None: + conditions = getattr(PluginsAutoConfiguration, "__pyfly_conditions__", []) + prop_cond = next(c for c in conditions if c["type"] == "on_property") + assert prop_cond["key"] == "pyfly.plugins.enabled" + assert prop_cond["having_value"] == "true" + + def test_extension_registry_bean_produces_registry(self) -> None: + cfg = PluginsAutoConfiguration() + reg = cfg.extension_registry() + assert isinstance(reg, ExtensionRegistry) + + def test_plugin_manager_bean_produces_manager(self) -> None: + cfg = PluginsAutoConfiguration() + reg = cfg.extension_registry() + mgr = cfg.plugin_manager(reg) + assert isinstance(mgr, PluginManager) + + def test_plugin_manager_uses_provided_registry(self) -> None: + cfg = PluginsAutoConfiguration() + reg = cfg.extension_registry() + mgr = cfg.plugin_manager(reg) + assert mgr.registry is reg diff --git a/tests/plugins/test_sp11_review_fixes.py b/tests/plugins/test_sp11_review_fixes.py new file mode 100644 index 00000000..227c2440 --- /dev/null +++ b/tests/plugins/test_sp11_review_fixes.py @@ -0,0 +1,65 @@ +# Copyright 2026 Firefly Software Foundation. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Regressions for the SP-11 review: add() raises PluginLoadError; start_all() does not +re-run hooks for plugins already started via start_plugin().""" + +from __future__ import annotations + +import pytest + +from pyfly.kernel.exceptions import PluginException, PluginLoadError +from pyfly.plugins.decorators import plugin +from pyfly.plugins.manager import PluginManager +from pyfly.plugins.models import PluginState + + +@pytest.mark.asyncio +async def test_add_non_decorated_class_raises_plugin_load_error() -> None: + class NotAPlugin: + pass + + manager = PluginManager() + with pytest.raises(PluginLoadError): + await manager.add(NotAPlugin) + assert issubclass(PluginLoadError, PluginException) + + +@pytest.mark.asyncio +async def test_start_all_does_not_double_run_already_started_plugin() -> None: + calls: list[str] = [] + + @plugin(id="p1") + class P1: + async def start(self) -> None: + calls.append("p1") + + @plugin(id="p2", depends_on=("p1",)) + class P2: + async def start(self) -> None: + calls.append("p2") + + manager = PluginManager() + await manager.add(P1) + await manager.add(P2) + + await manager.start_plugin("p1") # p1 STARTED individually + assert calls == ["p1"] + + await manager.start_all() # must start only p2 (p1 already STARTED — no double-run) + assert calls == ["p1", "p2"] + + p1_desc = await manager.get_plugin("p1") + p2_desc = await manager.get_plugin("p2") + assert p1_desc is not None and p1_desc.state == PluginState.STARTED + assert p2_desc is not None and p2_desc.state == PluginState.STARTED diff --git a/uv.lock b/uv.lock index f8266630..e22ada86 100644 --- a/uv.lock +++ b/uv.lock @@ -2136,7 +2136,7 @@ wheels = [ [[package]] name = "pyfly" -version = "26.6.90" +version = "26.6.91" source = { editable = "." } dependencies = [ { name = "pydantic" },