Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
<a href="https://github.com/fireflyframework"><img src="https://img.shields.io/badge/Firefly_Framework-official-ff6600?logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZmlsbD0id2hpdGUiIGQ9Ik0xMiAyQzYuNDggMiAyIDYuNDggMiAxMnM0LjQ4IDEwIDEwIDEwIDEwLTQuNDggMTAtMTBTMTcuNTIgMiAxMiAyeiIvPjwvc3ZnPg==" alt="Firefly Framework"></a>
<a href="https://www.python.org/"><img src="https://img.shields.io/badge/python-3.12%2B-blue?logo=python&logoColor=white" alt="Python 3.12+"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-green" alt="License: Apache 2.0"></a>
<a href="#"><img src="https://img.shields.io/badge/version-26.06.90-brightgreen" alt="Version: 26.06.90"></a>
<a href="#"><img src="https://img.shields.io/badge/version-26.06.91-brightgreen" alt="Version: 26.06.91"></a>
<a href="#"><img src="https://img.shields.io/badge/type--checked-mypy%20strict-blue?logo=python&logoColor=white" alt="Type Checked: mypy strict"></a>
<a href="#"><img src="https://img.shields.io/badge/code%20style-ruff-purple?logo=ruff&logoColor=white" alt="Code Style: Ruff"></a>
<a href="#"><img src="https://img.shields.io/badge/async-first-brightgreen" alt="Async First"></a>
Expand Down
132 changes: 132 additions & 0 deletions docs/modules/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)` |
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/pyfly/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,4 @@
# limitations under the License.
"""PyFly — Enterprise Python Framework."""

__version__ = "26.06.90"
__version__ = "26.06.91"
11 changes: 11 additions & 0 deletions src/pyfly/kernel/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@
NotImplementedException,
OperationTimeoutException,
PayloadTooLargeException,
PluginException,
PluginLoadError,
PluginStartError,
PluginStateError,
PluginStopError,
PreconditionFailedException,
PyFlyException,
QuotaExceededException,
Expand Down Expand Up @@ -100,4 +105,10 @@
"BadGatewayException",
"GatewayTimeoutException",
"QuotaExceededException",
# Plugin
"PluginException",
"PluginLoadError",
"PluginStartError",
"PluginStopError",
"PluginStateError",
]
26 changes: 26 additions & 0 deletions src/pyfly/kernel/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)."""
3 changes: 3 additions & 0 deletions src/pyfly/plugins/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -27,8 +28,10 @@
"ExtensionRegistry",
"Plugin",
"PluginDependencyResolver",
"PluginDescriptor",
"PluginManager",
"PluginResolutionError",
"PluginState",
"extension",
"extension_point",
"plugin",
Expand Down
17 changes: 15 additions & 2 deletions src/pyfly/plugins/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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

Expand Down
Loading
Loading