Skip to content

Fix listener callback contracts across domain APIs (#72)#80

Merged
Faerkeren merged 1 commit into
mainfrom
fix/listener-callback-contracts
May 27, 2026
Merged

Fix listener callback contracts across domain APIs (#72)#80
Faerkeren merged 1 commit into
mainfrom
fix/listener-callback-contracts

Conversation

@Faerkeren

Copy link
Copy Markdown
Contributor

Issue validity assessment

Closes #72.

Verified valid and reproducible by reading the source:

  • Entity._schedule_value (src/haclient/entity/base.py:175-189) dispatches every granular listener with two positional arguments (old, new).
  • Several public on_* listener decorators across domain modules documented a zero-argument or single-value callback. A user following those docs would register a callable that raises TypeError when the event fires; the exception is caught and logged by _schedule_value, so from the caller's perspective the listener silently does nothing.
  • Every on_* method also used func: Any) -> Any, so mypy could not catch the mismatch at the boundary.

The runtime behaviour is already what the (small majority of) docs and every existing test expect, so the issue's recommended path — keep (old, new) and align docs + types with it — is correct.

Fix

Smallest correct change:

  • Standardise every domain listener docstring on the (old, new) contract: light, switch, binary_sensor, sensor, cover, lock, climate, humidifier, media_player, timer, vacuum, valve, scene, air_quality, fan.
  • Replace func: Any) -> Any with func: ValueChangeHandler) -> ValueChangeHandler on every public on_* method. ValueChangeHandler is Callable[[Any, Any], Any] defined in haclient.entity.base, so existing internal storage (list[ValueChangeHandler]) needed no change.
  • Drop now-unused typing.Any imports.

Timer's event-driven on_finished / on_cancelled listeners also receive two positional arguments ((entity_id, event_data)), which is structurally identical to ValueChangeHandler, so they get the same typed signature.

The event domain's on_event(self, func: Callable[[str], Any]) is a different, already-typed contract and was intentionally not touched.

Tests / checks

tests/test_granular_events.py already pins the (old, new) shape across every domain. Six new regression tests were added at the bottom:

  • test_state_transition_callback_receives_old_and_new — pins the transition-listener contract.
  • test_attr_change_callback_receives_old_and_new — pins the attribute-listener contract.
  • test_state_value_callback_receives_old_and_new — pins the state-value-listener contract.
  • test_zero_argument_callback_is_logged_and_skipped — verifies that a misused zero-argument callback raises TypeError, gets logged at ERROR, and does not break other handlers registered for the same event.
  • test_media_change_callback_receives_old_and_new — pins the MediaPlayer.on_media_change (NowPlaying, NowPlaying) contract.
  • test_timer_event_callback_receives_entity_id_and_data — pins the timer event-listener (entity_id, data) contract.

Validation run on this branch:

  • ruff check src tests — pass
  • ruff format --check src tests — pass
  • mypy src (strict) — Success: no issues found in 37 source files
  • pytest tests/ --cov=haclient --cov-fail-under=95291 passed, total coverage 97.07%

The runtime contract for every public on_* listener decorator is
(old, new), enforced by Entity._schedule_value. Several domain
docstrings still described the older zero-argument or single-value
shape, and every public on_* method used func: Any) -> Any so type
checking did not protect users from registering callbacks with the
wrong arity.

Changes:
- Standardise every domain listener docstring on the (old, new)
  contract (light, switch, binary_sensor, sensor, cover, lock,
  climate, humidifier, media_player, timer, vacuum, valve, scene,
  air_quality, fan).
- Replace func: Any) -> Any with func: ValueChangeHandler) ->
  ValueChangeHandler on every public on_* method so mypy users get
  arity protection at the boundary.
- Drop now-unused typing.Any imports.
- Add regression tests in tests/test_granular_events.py covering the
  attribute, state-transition, state-value, media-change, and
  timer-event listener shapes, plus a test pinning that a
  zero-argument callback raises a logged TypeError and does not
  silently no-op other handlers.
@Faerkeren Faerkeren merged commit 5905dc8 into main May 27, 2026
12 checks passed
@Faerkeren Faerkeren deleted the fix/listener-callback-contracts branch May 27, 2026 01:56
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.

Fix listener callback contracts across domain APIs

1 participant