Skip to content

Commit 27fd6a3

Browse files
authored
Implement required dependency injection for event handlers (#191)
* use new event classes instead of old casting * move some stuff down so we don't have to wrap in strings * fix accessors by using states.StateUnion, cast, and improve some return types * add accessors for state old/new * initial addition of dependency injection logic * these do not need to be generic, no benefit * improve DI * remove ability to pass positional args * majorly simplify handler types, remove all annotations from bus signatures * add back ability to be None * make base HassetteError, add new exception type * do not autodetect apps during tests * remove option no longer supported by pyright * fix tests * fix incorrect annotation * remove old args * remove args from test app handlers * remove unreachable code * move to module, split into files, rename to dependencies to match convention * update docs * update examples * fix examples * fix orphan docstring * change fallback logic * enforce no var args/positional only args regardlesss of using DI * simplify is_event_type check * add some missing exports * handle possibility of param type not having __name__ * check and warn on collisions * make it explicit that we don't handle unions or optionals * add a file full of test events * add another file of test events * switch to session scope since needed for DI test fixtures at session scope * use defined handler instead of asyncmock so it doesn't raise due to accepting args * add DI tests * make entity/domain accessors more robust * correct return type for context * remove unnecessary logic from get_service_data * formatting + comment * add logger * improve tests * flip validation order * remove Annotated: pattern is to use Annotated[type, D], so these shouldn't already be Anontated * fix tests * update documentation and examples * simplify annotations, excepting attr ones * remove redundant type * add assertion * fix tests * remove references to Depends * remove bold * bump minor
1 parent 88168ce commit 27fd6a3

37 files changed

+2213
-505
lines changed

.github/copilot-instructions.md

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,12 @@ These notes make AI coding agents productive quickly in this repo. Focus on the
2222
## App Pattern (typed)
2323

2424
- Base classes: `src/hassette/core/resources/app/app.py` exposes `App[AppConfigT]` (async) and `AppSync`. Override lifecycle hooks (`before_initialize`, `on_initialize`, `after_initialize`, and matching shutdown hooks); `initialize()` / `shutdown()` are final.
25-
- Example async app:
25+
- Example async app with dependency injection:
2626

2727
```python
28-
from hassette import App, AppConfig
28+
from typing import Annotated
29+
from hassette import App, AppConfig, states
30+
from hassette import dependencies as D
2931

3032
class MyConfig(AppConfig):
3133
light: str
@@ -38,12 +40,43 @@ These notes make AI coding agents productive quickly in this repo. Focus on the
3840
changed_to="on",
3941
)
4042

41-
async def on_light_change(self, event):
42-
await self.api.call_service("notify", "mobile_app_me", message="Light turned on")
43+
async def on_light_change(
44+
self,
45+
new_state: D.StateNew[states.LightState],
46+
entity_id: D.EntityId,
47+
):
48+
friendly_name = new_state.attributes.friendly_name or entity_id
49+
await self.api.call_service("notify", "mobile_app_me", message=f"{friendly_name} turned on")
4350
```
4451

4552
- Sync apps inherit `AppSync` and implement `on_initialize_sync` / `on_shutdown_sync`; use `self.api.sync.*` for blocking calls. `self.task_bucket` offers helpers (`spawn`, `run_in_thread`, `run_sync`) for background work.
4653

54+
## Dependency Injection for Event Handlers
55+
56+
- **Module**: `src/hassette/dependencies/` provides DI system for event handlers
57+
- **Purpose**: Automatically extract and inject event data into handler parameters using `Annotated` type hints
58+
- **Key files**:
59+
- `__init__.py` - Public API and examples
60+
- `classes.py` - Dependency marker classes (`StateNew`, `StateOld`, `AttrNew`, etc.)
61+
- `extraction.py` - Signature inspection and parameter extraction logic
62+
- **Usage pattern**:
63+
64+
```python
65+
from typing import Annotated
66+
from hassette import dependencies as D, states
67+
68+
async def handler(
69+
new_state: D.StateNew[states.LightState],
70+
brightness: Annotated[int | None, D.AttrNew("brightness")],
71+
entity_id: D.EntityId,
72+
):
73+
# Parameters automatically extracted and injected
74+
pass
75+
```
76+
77+
- **Available dependencies**: `StateNew`, `StateOld`, `StateOldAndNew`, `AttrNew(name)`, `AttrOld(name)`, `AttrOldAndNew(name)`, `EntityId`, `Domain`, `Service`, `StateValueNew`, `StateValueOld`, `ServiceData`, `EventContext`
78+
- **Integration**: `Bus` resource (`core/resources/bus/bus.py`) uses `extract_from_signature` and `validate_di_signature` to process handler signatures and inject values at call time
79+
4780
## Event Bus Usage
4881

4982
- Topics are defined in `src/hassette/topics.py` (`HASS_EVENT_STATE_CHANGED`, `HASSETTE_EVENT_SERVICE_STATUS`, `HASSETTE_EVENT_APP_LOAD_COMPLETED`, etc.).

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## Unreleased
99

10+
## [0.16.0] - 2025-11-16
11+
1012
### Added
1113
- Added `ANY_VALUE` sentinel for clearer semantics in predicates - use this to indicate "any value is acceptable"
14+
- **Dependency Injection for Event Handlers** - Handlers can now use `Annotated` type hints with dependency markers from `hassette.dependencies` to automatically extract and inject event data as parameters. This provides a cleaner, more type-safe alternative to manually accessing event payloads.
15+
- Available dependencies include `StateNew`, `StateOld`, `AttrNew(name)`, `AttrOld(name)`, `EntityId`, `Domain`, `Service`, `ServiceData`, `StateValueNew`, `StateValueOld`, `EventContext`, and more
16+
- Handlers can mix DI parameters with custom kwargs
17+
- See `hassette.dependencies` module documentation and updated examples for details
1218

1319
### Changed
20+
- **Breaking:** - Event handlers can no longer receive positional only args or variadic positional args
1421
- `NOT_PROVIDED` predicate is now used only to indicate that a parameter was not provided to a function
1522

1623
## [0.15.5] - 2025-11-14

README.md

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ A simple, modern, async-first Python framework for building Home Assistant autom
1919

2020
- **Type Safe**: Full type annotations with Pydantic models and comprehensive IDE support
2121
- **Async-First**: Built for modern Python with async/await throughout
22+
- **Dependency Injection**: Clean handler signatures with automatic parameter extraction
2223
- **Simple & Focused**: Just Home Assistant automations - no complexity creep
2324
- **Developer Experience**: Clear error messages, proper logging, hot-reloading, and intuitive APIs
2425

@@ -33,7 +34,9 @@ pip install hassette
3334
Create a simple app (`apps/hello.py`):
3435

3536
```python
36-
from hassette import App
37+
from typing import Annotated
38+
from hassette import App, states
39+
from hassette import dependencies as D
3740

3841
class HelloApp(App):
3942
async def on_initialize(self):
@@ -43,11 +46,15 @@ class HelloApp(App):
4346
changed_to="on"
4447
)
4548

46-
async def on_door_open(self, event):
47-
self.logger.info("Front door opened!")
49+
async def on_door_open(
50+
self,
51+
new_state: D.StateNew[states.BinarySensorState],
52+
entity_id: D.EntityId,
53+
):
54+
self.logger.info("%s opened!", entity_id)
4855
await self.api.call_service(
4956
"notify", "mobile_app_phone",
50-
message="Front door opened!"
57+
message=f"{new_state.attributes.friendly_name or entity_id} opened!"
5158
)
5259
```
5360

agents.md

Lines changed: 69 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,9 @@ API calls reuse both the shared WebSocket and REST client managed by `ApiResourc
4242
Apps inherit from `App[AppConfigT]` (async) or `AppSync` (sync) and implement lifecycle hooks:
4343

4444
```python
45-
from hassette import App, AppConfig
45+
from typing import Annotated
46+
from hassette import App, AppConfig, states
47+
from hassette import dependencies as D
4648

4749
class MyConfig(AppConfig):
4850
light: str
@@ -55,8 +57,13 @@ class MyApp(App[MyConfig]):
5557
changed_to="on",
5658
)
5759

58-
async def on_light_change(self, event):
59-
await self.api.call_service("notify", "mobile_app_me", message="Light turned on")
60+
async def on_light_change(
61+
self,
62+
new_state: D.StateNew[states.LightState],
63+
entity_id: D.EntityId,
64+
):
65+
friendly_name = new_state.attributes.friendly_name or entity_id
66+
await self.api.call_service("notify", "mobile_app_me", message=f"{friendly_name} turned on")
6067
```
6168

6269
### Key Lifecycle Hooks
@@ -103,6 +110,64 @@ config = {threshold = 10, always_send = false}
103110
**Required fields**: `filename`, `class_name`
104111
**Optional fields**: `app_dir`, `enabled`, `display_name`, `config`
105112

113+
## Dependency Injection System
114+
115+
**Location**: `src/hassette/dependencies/`
116+
117+
Hassette provides a dependency injection system for event handlers, allowing automatic extraction and injection of event data as handler parameters.
118+
119+
### Key Components
120+
121+
**Files**:
122+
- `dependencies/__init__.py` - Public API and documentation
123+
- `dependencies/classes.py` - Dependency marker classes
124+
- `dependencies/extraction.py` - Signature inspection and extraction logic
125+
126+
**Integration**: The `Bus` resource (`core/resources/bus/bus.py`) uses `extract_from_signature` and `validate_di_signature` to process handler signatures and inject values at runtime.
127+
128+
### Available Dependencies
129+
130+
**State Extractors**:
131+
- `StateNew` - Extract new state object from state change events
132+
- `StateOld` - Extract old state object (may be None)
133+
- `StateOldAndNew` - Extract both states as tuple
134+
135+
**Attribute Extractors**:
136+
- `AttrNew("attribute_name")` - Extract attribute from new state
137+
- `AttrOld("attribute_name")` - Extract attribute from old state
138+
- `AttrOldAndNew("attribute_name")` - Extract from both states
139+
140+
**Value & Identity Extractors**:
141+
- `EntityId` - Extract entity ID from any event
142+
- `Domain` - Extract domain (e.g., "light", "sensor")
143+
- `Service` - Extract service name from service call events
144+
- `StateValueNew` / `StateValueOld` - Extract state value strings
145+
- `ServiceData` - Extract service_data dict from service calls
146+
- `EventContext` - Extract Home Assistant event context
147+
148+
### Usage Pattern
149+
150+
```python
151+
from typing import Annotated
152+
from hassette import states
153+
from hassette import dependencies as D
154+
155+
async def handler(
156+
new_state: D.StateNew[states.LightState],
157+
brightness: Annotated[int | None, D.AttrNew("brightness")],
158+
entity_id: D.EntityId,
159+
):
160+
# Parameters automatically extracted and injected
161+
if brightness and brightness > 200:
162+
self.logger.info("%s is bright: %d", entity_id, brightness)
163+
```
164+
165+
### Restrictions
166+
167+
- Handlers cannot use positional-only parameters (before `/`)
168+
- Handlers cannot use variadic positional args (`*args`)
169+
- All DI parameters must have type annotations
170+
106171
## Event Bus Usage
107172

108173
### Topics and Events
@@ -111,7 +176,7 @@ config = {threshold = 10, always_send = false}
111176

112177
### Bus Helpers
113178
```python
114-
# State change handlers
179+
# State change handlers with DI
115180
self.bus.on_state_change("binary_sensor.motion", handler=self.on_motion, changed_to="on")
116181
self.bus.on_attribute_change("mobile_device.me", "battery_level", handler=self.on_battery_drop)
117182
self.bus.on_call_service(domain="light", service="turn_on", handler=self.on_turn_on)

docs/pages/appdaemon-comparison.md

Lines changed: 91 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -344,11 +344,57 @@ The event bus is accessed via the `self.bus` attribute. You can cancel a
344344
subscription using the `Subscription` object returned by the listen
345345
method, e.g., `subscription.cancel()`.
346346

347+
!!! warning
348+
Handlers **cannot** use positional-only parameters (parameters before `/`) or variadic positional arguments (`*args`).
347349

348-
The event object is not a required argument - if you do not need it, simply
349-
omit it from your handler's signature. If you do include it, ensure it is the
350-
first unbound argument in your function signature and it is named event (adding
351-
some dependency injection logic is on the roadmap).
350+
**Dependency Injection for Handlers**
351+
352+
Hassette supports dependency injection for event handlers, allowing you to extract
353+
specific data from events without manually accessing the event payload. Use the
354+
[Annotated][typing.Annotated] type
355+
hint with dependency markers from [dependencies][hassette.dependencies]:
356+
357+
```python
358+
from typing import Annotated
359+
from hassette import App, states
360+
from hassette import dependencies as D
361+
from hassette.events import CallServiceEvent
362+
363+
364+
class ButtonHandler(App):
365+
async def on_initialize(self):
366+
# Handler with dependency injection
367+
sub = self.bus.on_call_service(
368+
service="press",
369+
handler=self.minimal_callback,
370+
where={"entity_id": "input_button.test_button"},
371+
)
372+
self.logger.info(f"Subscribed: {sub}")
373+
374+
# Extract only what you need from the event
375+
async def minimal_callback(
376+
self,
377+
domain: D.Domain,
378+
service: D.Service,
379+
service_data: D.ServiceData,
380+
) -> None:
381+
entity_id = service_data.get("entity_id", "unknown")
382+
self.logger.info(f"Button {entity_id} pressed (domain={domain}, service={service})")
383+
```
384+
385+
Available dependency markers include:
386+
- `StateNew`, `StateOld` - Extract state objects from state change events
387+
- `AttrNew("name")`, `AttrOld("name")` - Extract specific attributes
388+
- `EntityId`, `Domain`, `Service` - Extract identifiers
389+
- `StateValueNew`, `StateValueOld` - Extract state values (e.g., "on"/"off")
390+
- `ServiceData`, `EventContext` - Extract service data or event context
391+
392+
You can also receive the full event object if you prefer:
393+
394+
```python
395+
async def minimal_callback(self, event: CallServiceEvent) -> None:
396+
self.logger.info(f"Button pressed: {event.payload.data.service_data}")
397+
```
352398

353399

354400
```python
@@ -435,11 +481,11 @@ class ButtonPressed(Hass):
435481

436482
State change handlers are the exact same as event handlers - we're only
437483
calling them out separately to align with AppDaemon. These can also be
438-
either async or sync functions and accept any arguments - including the event object, if desired.
439-
The event bus provides helpers for
440-
filtering entities and attributes. You can also provide additional
441-
predicates using the `where` parameter. In this example, we listen for
442-
any state change on the specified entity.
484+
either async or sync functions. You can receive the full event object or
485+
use dependency injection to extract only the data you need.
486+
487+
The event bus provides helpers for filtering entities and attributes. You can
488+
also provide additional predicates using the `where` parameter.
443489

444490
Like other objects, these are typed using Pydantic models -
445491
`StateChangeEvent` is a
@@ -451,14 +497,43 @@ Currently the repr of a StateChangeEvent is quite verbose, but it does
451497
show the full old and new state objects, which can be useful for
452498
debugging. Cleaning this up is on the roadmap.
453499

500+
**With dependency injection** (recommended):
501+
502+
```python
503+
from typing import Annotated
504+
from hassette import App, states
505+
from hassette import dependencies as D
506+
507+
508+
class ButtonPressed(App):
509+
async def on_initialize(self):
510+
sub = self.bus.on_state_change(
511+
entity="input_button.test_button",
512+
handler=self.button_pressed,
513+
)
514+
self.logger.info(f"Subscribed: {sub}")
515+
516+
async def button_pressed(
517+
self,
518+
new_state: D.StateNew[states.ButtonState],
519+
entity_id: D.EntityId,
520+
) -> None:
521+
friendly_name = new_state.attributes.friendly_name or entity_id
522+
self.logger.info(f"Button {friendly_name} pressed at {new_state.last_changed}")
523+
```
524+
525+
**With event object**:
526+
454527
```python
455528
from hassette import App, StateChangeEvent, states
456529
457530
458531
class ButtonPressed(App):
459532
async def on_initialize(self):
460-
# Listen for a button press event with a specific entity_id
461-
sub = self.bus.on_state_change(entity="input_button.test_button", handler=self.button_pressed)
533+
sub = self.bus.on_state_change(
534+
entity="input_button.test_button",
535+
handler=self.button_pressed,
536+
)
462537
self.logger.info(f"Subscribed: {sub}")
463538
464539
def button_pressed(self, event: StateChangeEvent[states.ButtonState]) -> None:
@@ -571,11 +646,13 @@ entity will raise a `EntityNotFoundError` exception.
571646

572647
The API client is accessed via the `self.api` attribute. This client
573648
makes direct calls to Home Assistant over REST API, which does require
574-
using `await`. A state cache, similar to
575-
AppDaemon's, is on the roadmap. When you call
576-
`set_state()`, it uses the Home Assistant
649+
using `await`. When you call `set_state()`, it uses the Home Assistant
577650
REST API to update the state of the entity.
578651

652+
!!! info "State Cache Coming Soon"
653+
A state cache similar to AppDaemon's is planned for a future release,
654+
which will reduce API calls for frequently accessed states.
655+
579656
```python
580657
from hassette.models import states
581658

0 commit comments

Comments
 (0)