Skip to content

feat(events): Laravel-style event system (dispatcher, Event facade, event() helper)#172

Open
tmgbedu wants to merge 3 commits into
mainfrom
task/event-system-940
Open

feat(events): Laravel-style event system (dispatcher, Event facade, event() helper)#172
tmgbedu wants to merge 3 commits into
mainfrom
task/event-system-940

Conversation

@tmgbedu

@tmgbedu tmgbedu commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Summary

Implements a basic, async-aware event system for the core framework whose public API mirrors Laravel's Events, adapted to this framework's container/provider/facade architecture (task #940).

What's included (in scope)

  • Dispatcher (events/dispatcher.py)
    • listen(events, listener) — register by event class, string name, or a list; also usable as a decorator
    • dispatch(event, payload=None, halt=False) — fires listeners in registration order, returns their responses; a listener returning False stops propagation
    • until(event) / halt — return the first non-None response
    • has_listeners, forget, flush
    • Listeners may be callables or classes with a handle method, sync or async; class listeners are resolved through the container (dependency injection)
  • EventServiceProvider (events/provider.py) — registered by default; subclass and set a listen map to wire events → listeners
  • Event facade (facades/Event.py + .pyi stub) and a global event() helper
  • Event.fake() testing double (events/fake.py) with assert_dispatched, assert_dispatched_times, assert_not_dispatched, assert_nothing_dispatched, dispatched(...), and partial-fake passthrough to the real dispatcher
  • Optional Listener ABC documenting the handle contract

Wiring

  • EventServiceProvider added to Application.DEFAULT_PROVIDERS so events is bound out of the box
  • Event exported from fastapi_startkit.facades

Out of scope (backlog notes filed separately)

Queued listeners, broadcasting integration, event discovery/auto-registration, and model observers.

Testing

  • New tests/events/ suite: 36 tests, 100% coverage of the events package
  • Full framework suite: 1832 passed, 7 skipped (postgres excluded)
  • ruff check / ruff format clean; pyright reports 0 errors on the new source

…vent() helper

Add a basic async-aware event system to the core framework:

- Dispatcher: listen (class/string/list events, decorator form), dispatch,
  until/halt, response collection, false-stops-propagation, has_listeners,
  forget, flush. Supports callable and class-based (handle) listeners, both
  sync and coroutine, with container dependency injection for listener classes.
- EventServiceProvider: registered by default; subclass and set a listen map
  to wire events to listeners.
- Event facade + .pyi stub and a global event() helper.
- EventFake (Event.fake()) recording test double with assert_dispatched,
  assert_dispatched_times, assert_not_dispatched, assert_nothing_dispatched,
  and partial-fake passthrough to the real dispatcher.

Full unit coverage for the events package.
@codecov

codecov Bot commented Jul 13, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@tmgbedu tmgbedu left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review — REQUEST CHANGES 🔴 (one blocker; rest is solid)

Reviewed the full diff, ran the suite, and traced the dispatcher semantics against Laravel's Illuminate\Events\Dispatcher. The core implementation is clean and faithful, but there is one blocking gap against the acceptance criteria.

🔴 Blocker — wildcard listeners do not fire

Dispatcher.listen("user.*", ...) registers under the literal key "user.*", and dispatch does a plain self._listeners.get(name, []) exact-key lookup (dispatcher.py _parse_event_payload + dispatch). There is no pattern matching, so dispatch("user.created") never reaches a "user.*" listener. Laravel supports wildcard listeners and this is a required AC (confirmed by QA / PM, task #948).

  • Fix required: on dispatch, in addition to exact matches, also match any registered pattern containing * against the fired event name (Laravel uses Str::is, i.e. fnmatch-style). Wildcard listeners in Laravel receive (event_name, payload) — decide and document the callback signature, and add tests for: exact-only, wildcard-only, both firing together, and no over-match (e.g. "user.*" must not fire for "user" or "users.created" unless intended).
  • Add coverage for the wildcard path in EventFake too, since _should_fake/recording key off the exact name.

The Events Engineer is pushing this fix to the same branch — I'll re-check the wildcard path once it lands.

✅ Everything else verified and correct

  • listen by class / string / list / decorator; keys are module.qualname (no cross-module collision). ✔
  • dispatch matches Laravel: registration order; halt/until return first non-None; a listener returning False breaks propagation and is not appended; halt returns None when nothing matched. ✔
  • has_listeners / forget / flush correct. ✔
  • Listener resolution — callables invoked directly; classes resolved via container resolve so constructor DI works (test_listener_class_dependencies_resolved_by_container); cls() fallback with no container. ✔
  • Async awaited / sync directinspect.isawaitable gate; async listener + async handle both tested. ✔
  • Event.fake() — rebinds events, records without invoking listeners, partial-fake passthrough verified (real listener actually fires). Assertion helpers all correct. ✔
  • Facade + .pyi__getattr__ resolves events fresh each call so fakes take effect; stubs match runtime. ✔
  • event() helper dispatches via the container binding (respects fakes). ✔
  • EventServiceProvider registered in Application.DEFAULT_PROVIDERS; bound out of the box. ✔

Test quality (for the shipped scope)

Real side-effect assertions (ordered lists, DI'd mailer, propagation order), real Container for DI, ABC enforced via pytest.raises(TypeError). No hollow mocks. 36/36 pass, 100% events coverage, full suite 1835 passed / 7 skipped, ruff clean, pyright 0 errors. Once wildcards land, the same bar must apply — real over/under-match assertions, not just a happy-path test.

Non-blocking note

  • EventFake doesn't proxy forget/flush, both exposed on Event.pyiAttributeError if called while faked. Consider a passthrough.

Holding at request-changes until the wildcard fix (task #948) lands; will re-review that path then.

Event.listen('user.*') now fires for any matching event name. dispatch()
(and until/halt and has_listeners) collect exact-key listeners first, then any
wildcard-pattern listeners whose pattern matches the dispatched name via
fnmatchcase. Non-matching events do not fire. Adds tests for match, non-match,
multi-pattern, wildcard+exact ordering, multi-segment match, until/halt, and
has_listeners.
@tmgbedu

tmgbedu commented Jul 13, 2026

Copy link
Copy Markdown
Contributor Author

Update — wildcard listener support (task #948)

Fixes the QA finding: wildcard listeners now fire.

  • Dispatcher.dispatch() now collects exact-key listeners first, then any wildcard-pattern listeners whose pattern matches the event name via fnmatch.fnmatchcase (e.g. Event.listen("user.*") fires on user.created and user.profile.updated).
  • Non-matching events do not fire; exact/class listeners and until/halt still work. has_listeners also honors wildcard matches.

Tests added (match, non-match, payload passthrough, multi-pattern, wildcard+exact ordering, multi-segment match, until halt across exact+wildcard, has_listeners).

Events suite: 44 tests, 100% coverage of the events package. Full suite: 1843 passed, 7 skipped. ruff + pyright clean.

The Event facade stub exposes forget() and flush(), but EventFake did not
implement them, so calling Event.forget()/Event.flush() while faked raised
AttributeError. Forward both to the real dispatcher to match its surface.
@tmgbedu

tmgbedu commented Jul 13, 2026

Copy link
Copy Markdown
Contributor Author

Update — EventFake forget()/flush() passthrough (task #949)

Non-blocking review find: the Event facade stub exposes forget()/flush(), but EventFake didn't implement them, so Event.forget()/Event.flush() while faked raised AttributeError. Both now forward to the real dispatcher, matching its surface. Test added.

Events suite: 45 tests, 100% coverage. ruff + pyright clean.

@tmgbedu tmgbedu left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review — APPROVE ✅ (both prior findings resolved)

Re-reviewed after commits 88537bf8 (wildcards) and c58b9a16 (fake forget/flush). Both items from my earlier review are fully addressed. No blockers remain.

✅ Blocker resolved — wildcard listeners (88537bf8)

_get_listeners() now returns exact matches first, then any registered pattern containing * that fnmatchcase(name, pattern) matches. dispatch and has_listeners both route through it. I independently verified the over/under-match boundaries against the running code:

  • "user.*" → fires for "user.created" and "user.profile.updated"
  • "user.*" → does NOT fire for "user" (no dot), "users.created" (prefix bleed), or "xuser.created" (suffix bleed) ✔ — exactly my required cases
  • "*.created" → fires for "order.created" / "a.b.created", not for "created" / "created.x"
  • forget("user.*") removes the wildcard listener; until/halt spans exact+wildcard; exact fires before wildcard ✔
    fnmatchcase (case-sensitive, deterministic) is the right primitive here. 8 new wildcard tests cover match / non-match / multi-pattern / multi-segment / exact+wildcard ordering / until-halt / has_listeners.

✅ Non-blocker resolved — EventFake.forget/flush (c58b9a16)

Both now proxy to the real dispatcher; test_forget_and_flush_pass_through_to_real_dispatcher asserts selective forget + full flush. No more AttributeError when called while faked.

Design note (non-blocking, intentional + tested)

Wildcard listeners receive the same args as exact listeners (the payload), not (event_name, payload) as in Laravel — so a "user.*" listener can't tell which concrete event fired. This is a deliberate, documented, tested contract (test_wildcard_receives_event_payload). Fine for this scope; worth revisiting if event-name introspection is ever needed.

Verification (local, this branch)

  • tests/events: 45 passed, events package 100% coverage
  • Full suite: 1844 passed / 7 skipped (postgres excluded)
  • ruff clean · pyright 0 errors

All acceptance criteria satisfied. Approving. (Posted as a comment because GitHub won't let the authoring account self-approve — verdict is APPROVE.)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant