feat(events): Laravel-style event system (dispatcher, Event facade, event() helper)#172
feat(events): Laravel-style event system (dispatcher, Event facade, event() helper)#172tmgbedu wants to merge 3 commits into
Conversation
…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 Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
tmgbedu
left a comment
There was a problem hiding this comment.
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 usesStr::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
EventFaketoo, 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
listenby class / string / list / decorator; keys aremodule.qualname(no cross-module collision). ✔dispatchmatches Laravel: registration order;halt/untilreturn first non-None; a listener returningFalsebreaks propagation and is not appended;haltreturnsNonewhen nothing matched. ✔has_listeners/forget/flushcorrect. ✔- Listener resolution — callables invoked directly; classes resolved via container
resolveso constructor DI works (test_listener_class_dependencies_resolved_by_container);cls()fallback with no container. ✔ - Async awaited / sync direct —
inspect.isawaitablegate; async listener + asynchandleboth tested. ✔ Event.fake()— rebindsevents, records without invoking listeners, partial-fake passthrough verified (real listener actually fires). Assertion helpers all correct. ✔- Facade +
.pyi—__getattr__resolveseventsfresh each call so fakes take effect; stubs match runtime. ✔ event()helper dispatches via the container binding (respects fakes). ✔EventServiceProviderregistered inApplication.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
EventFakedoesn't proxyforget/flush, both exposed onEvent.pyi→AttributeErrorif 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.
Update — wildcard listener support (task #948)Fixes the QA finding: wildcard listeners now fire.
Tests added (match, non-match, payload passthrough, multi-pattern, wildcard+exact ordering, multi-segment match, Events suite: 44 tests, 100% coverage of the |
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.
Update — EventFake forget()/flush() passthrough (task #949)Non-blocking review find: the Events suite: 45 tests, 100% coverage. ruff + pyright clean. |
tmgbedu
left a comment
There was a problem hiding this comment.
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)
ruffclean ·pyright0 errors
All acceptance criteria satisfied. Approving. (Posted as a comment because GitHub won't let the authoring account self-approve — verdict is APPROVE.)
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 decoratordispatch(event, payload=None, halt=False)— fires listeners in registration order, returns their responses; a listener returningFalsestops propagationuntil(event)/halt— return the first non-Noneresponsehas_listeners,forget,flushhandlemethod, sync or async; class listeners are resolved through the container (dependency injection)EventServiceProvider(events/provider.py) — registered by default; subclass and set alistenmap to wire events → listenersEventfacade (facades/Event.py+.pyistub) and a globalevent()helperEvent.fake()testing double (events/fake.py) withassert_dispatched,assert_dispatched_times,assert_not_dispatched,assert_nothing_dispatched,dispatched(...), and partial-fake passthrough to the real dispatcherListenerABC documenting thehandlecontractWiring
EventServiceProvideradded toApplication.DEFAULT_PROVIDERSsoeventsis bound out of the boxEventexported fromfastapi_startkit.facadesOut of scope (backlog notes filed separately)
Queued listeners, broadcasting integration, event discovery/auto-registration, and model observers.
Testing
tests/events/suite: 36 tests, 100% coverage of theeventspackageruff check/ruff formatclean;pyrightreports 0 errors on the new source