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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

---

## v26.06.41 (2026-06-07)

### Added (context — injectable ApplicationEventPublisher + arbitrary domain events)

- **`ApplicationEventPublisher`** is now an injectable singleton bean (Spring's
`ApplicationEventPublisher`): inject it into any bean and `await publisher.publish(event)`
to fire events into the context event bus.
- **Arbitrary domain events** — the event bus and `@app_event_listener` no longer require
events to subclass `ApplicationEvent`; any object can be published, and a listener whose
parameter type matches (by `isinstance`) receives it (an untyped/`Any` parameter still
falls back to the catch-all `ApplicationEvent`).

`ApplicationEventPublisher` is exported from `pyfly.context`.

## v26.06.40 (2026-06-07)

### Added (context — more @ConditionalOn* conditions)
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.40-brightgreen" alt="Version: 26.06.40"></a>
<a href="#"><img src="https://img.shields.io/badge/version-26.06.41-brightgreen" alt="Version: 26.06.41"></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
1,149 changes: 1,149 additions & 0 deletions docs/superpowers/plans/2026-06-07-pyfly-by-example-phase0.md

Large diffs are not rendered by default.

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.40"
version = "26.6.41"
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.40"
__version__ = "26.06.41"
2 changes: 2 additions & 0 deletions src/pyfly/context/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
from pyfly.context.events import (
ApplicationEvent,
ApplicationEventBus,
ApplicationEventPublisher,
ApplicationReadyEvent,
ContextClosedEvent,
ContextRefreshedEvent,
Expand All @@ -41,6 +42,7 @@
"ApplicationContext",
"ApplicationEvent",
"ApplicationEventBus",
"ApplicationEventPublisher",
"ApplicationReadyEvent",
"BeanPostProcessor",
"ContextClosedEvent",
Expand Down
15 changes: 11 additions & 4 deletions src/pyfly/context/application_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
from pyfly.context.events import (
ApplicationEvent,
ApplicationEventBus,
ApplicationEventPublisher,
ApplicationReadyEvent,
ContextClosedEvent,
ContextRefreshedEvent,
Expand Down Expand Up @@ -80,6 +81,10 @@ def __init__(self, config: Config) -> None:
self._container._registrations[Config].instance = config
self._container.register(Container, scope=Scope.SINGLETON)
self._container._registrations[Container].instance = self._container
# Injectable event publisher (Spring ApplicationEventPublisher) — beans can fire
# lifecycle or arbitrary domain events into the bus.
self._container.register(ApplicationEventPublisher, scope=Scope.SINGLETON)
self._container._registrations[ApplicationEventPublisher].instance = ApplicationEventPublisher(self._event_bus)

# ------------------------------------------------------------------
# Bean registration
Expand Down Expand Up @@ -688,13 +693,15 @@ def _wire_app_event_listeners(self) -> None:
# return annotation must not be mistaken for the event (audit #119).
hints = typing.get_type_hints(method)
hints.pop("return", None)
event_type: type[ApplicationEvent] | None = None
# The first type-annotated parameter is the event type — any type, so a
# listener can subscribe to arbitrary domain events, not only ApplicationEvent.
event_type: type = ApplicationEvent
for param_type in hints.values():
if isinstance(param_type, type) and issubclass(param_type, ApplicationEvent):
# A concrete class (not typing.Any, which is a class in 3.11+ but is
# the "untyped" catch-all here) becomes the subscribed event type.
if isinstance(param_type, type) and param_type is not typing.Any:
event_type = param_type
break
if event_type is None:
event_type = ApplicationEvent
self._event_bus.subscribe(event_type, method, owner_cls=type(reg.instance))
count += 1
self._wiring_counts["event_listeners"] = count
Expand Down
40 changes: 33 additions & 7 deletions src/pyfly/context/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,34 +54,60 @@ class ApplicationEventBus:

def __init__(self) -> None:
self._listeners: dict[
type[ApplicationEvent],
type,
list[tuple[Callable[..., Awaitable[None]], type | None]],
] = {}

def subscribe(
self,
event_type: type[ApplicationEvent],
event_type: type,
listener: Callable[..., Awaitable[None]],
*,
owner_cls: type | None = None,
) -> None:
"""Register a listener for a specific event type."""
"""Register a listener for a specific event type (any type, not only ApplicationEvent)."""
if event_type not in self._listeners:
self._listeners[event_type] = []
self._listeners[event_type].append((listener, owner_cls))
# Pre-sort so publish() doesn't need to sort per invocation
self._listeners[event_type].sort(key=lambda e: get_order(e[1]) if e[1] else 0)

async def publish(self, event: ApplicationEvent) -> None:
async def publish(self, event: object) -> None:
"""Publish an event to all matching listeners (pre-sorted by @order).

Listeners may be synchronous (``void``) or coroutine functions; the
result is awaited only when awaitable, so a plain ``def`` listener does
not crash startup (audit #115).
*event* may be any object — lifecycle ``ApplicationEvent`` subclasses or arbitrary
domain events. Listeners may be synchronous (``void``) or coroutine functions; the
result is awaited only when awaitable, so a plain ``def`` listener does not crash
startup (audit #115).
"""
for event_type, entries in self._listeners.items():
if isinstance(event, event_type):
for listener, _owner in entries:
result = listener(event)
if inspect.isawaitable(result):
await result


class ApplicationEventPublisher:
"""Injectable publisher for firing application events into the context event bus.

Inject it into any bean and publish lifecycle or arbitrary domain events::

@service
class OrderService:
def __init__(self, events: ApplicationEventPublisher) -> None:
self._events = events

async def place(self, order: Order) -> None:
await self._events.publish(OrderPlacedEvent(order.id))

Any ``@app_event_listener`` whose parameter type matches the published event (by
``isinstance``) is invoked. The Spring ``ApplicationEventPublisher`` equivalent.
"""

def __init__(self, bus: ApplicationEventBus) -> None:
self._bus = bus

async def publish(self, event: object) -> None:
"""Publish *event* (any object) to all matching listeners."""
await self._bus.publish(event)
71 changes: 71 additions & 0 deletions tests/context/test_event_publisher.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# 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.
"""Injectable ApplicationEventPublisher + arbitrary domain events (v26.06.41)."""

from __future__ import annotations

import pytest

from pyfly.container import service
from pyfly.context.application_context import ApplicationContext
from pyfly.context.events import ApplicationEventPublisher, app_event_listener
from pyfly.core.config import Config


class OrderPlaced:
"""An arbitrary domain event — NOT an ApplicationEvent subclass."""

def __init__(self, order_id: str) -> None:
self.order_id = order_id


_received: list[str] = []


@service
class OrderListener:
@app_event_listener
async def on_order(self, event: OrderPlaced) -> None:
_received.append(event.order_id)


@service
class OrderService:
def __init__(self, events: ApplicationEventPublisher) -> None:
self.events = events

async def place(self, order_id: str) -> None:
await self.events.publish(OrderPlaced(order_id))


@pytest.mark.asyncio
async def test_injectable_publisher_delivers_domain_event() -> None:
_received.clear()
ctx = ApplicationContext(Config({}))
ctx.register_bean(OrderListener)
ctx.register_bean(OrderService)
await ctx.start()

svc = ctx.get_bean(OrderService)
assert isinstance(svc.events, ApplicationEventPublisher) # publisher was injected

await svc.place("order-1")
assert _received == ["order-1"] # arbitrary domain event reached the @app_event_listener


@pytest.mark.asyncio
async def test_publisher_is_a_singleton_bean() -> None:
ctx = ApplicationContext(Config({}))
await ctx.start()
assert isinstance(ctx.get_bean(ApplicationEventPublisher), ApplicationEventPublisher)
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading