refactor: preserve ordered isinstance dispatch - #773
Conversation
|
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #773 +/- ##
===========================================
+ Coverage 84.14% 95.02% +10.87%
===========================================
Files 10 35 +25
Lines 801 5606 +4805
===========================================
+ Hits 674 5327 +4653
- Misses 127 279 +152
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Closes FutureTense#656 Split the two flagged isinstance ladders into focused helpers while preserving their ordered dispatch semantics. Coordinator datetime values still flow through temporal conversion before collection/scalar serialization, and Zigbee2MQTT query results still handle HomeAssistantError before generic Exception and BaseException propagation. Assisted-by: GitHub Copilot CLI 1.0.82 (Claude Opus 5, model claude-opus-5) Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
08ade63 to
5969c4d
Compare
There was a problem hiding this comment.
🟢 Approval recommended
The refactor preserves the original dispatch ordering and behavior, and the added tests directly assert the intended semantics for both modified sites.
Review details
- Files reviewed: 4/4 changed files
- Comments generated: 0 new
- Review effort level: Lite
secondof9
left a comment
There was a problem hiding this comment.
📋 Review Summary
Tip
Review Status: 🟢 APPROVED
Change Type: 🛠️ Refactor (ordered-dispatch extraction)
Review Effort: 🟡 Medium
Core Impact: Splits two isinstance ladders in coordinator.py and zigbee2mqtt.py into named helper methods, preserving the original most-specific-first dispatch order, and adds regression tests for both paths. No behavior change; a match/class-pattern block replaces the elif chain in the Z2M provider.
🚦 CI & Pipeline Health Summary
| Check / Workflow Name | Status | Impact on Review |
|---|---|---|
Pytest (3.14) |
✅ PASSED | 1186 passed, 3 skipped, 1 deselected; coverage 95.02%. |
coverage |
✅ PASSED | Codecov confirms all modified lines covered; 95.02% project coverage. |
Prek |
✅ PASSED | Pre-commit / lint clean. |
HACS Validation |
✅ PASSED | No integration manifest regression. |
Hassfest Validation |
✅ PASSED | No manifest regression. |
Autolabel PR |
✅ PASSED | Labeled correctly. |
Autolabel PR (re-run) |
⏩ SKIPPED | Duplicate run, benign. |
Note
CI Pipeline Clear: All 7 GitHub Actions workflows completed successfully. No failures, no in-progress runs, and the only skipped run is a benign duplicate Autolabel PR rerun.
🔍 Architectural Walkthrough
Coordinator: dataclass field serialization helper extraction
custom_components/keymaster/coordinator.py- The inline
isinstanceladder inside_kmlocks_to_dict(datetime → time → list → dict → scalar) is extracted into three named helpers:_kmlock_field_to_dict(composes the two stages),_kmlock_temporal_value_to_dict(preserves the sequentialdatetime-then-timereassignment), and_kmlock_collection_value_to_dict(preserves the list/dict recursive behavior withhasattr(item, "__dataclass_fields__")). - Semantic preservation is correct: temporal conversion runs to completion (rewriting
field_valueto an ISOstrin stage 1) before collection handling runs in stage 2, so adatetimeis never re-classified as alist/dict. The original sequentialif … if …pattern was also non-short-circuiting across types (each test was independent on a value that may have been reassigned), and the helper split preserves that exact semantics by reassigning the samefield_valuereference between the two stages. - The class is a
KeymasterCoordinatorand these helpers are boundselfmethods; the existingcoordinator_for_dictfixture in the tests is a perfectly valid call site, and the new test exercises it directly without mocking.
- The inline
Zigbee2MQTT provider: ordered exception-dispatch extraction
custom_components/keymaster/providers/zigbee2mqtt.py- The per-result
isinstancechain inasync_get_usercodesis extracted into_handle_usercode_query_result(slot_num, res). The new implementation usesmatch/caseclass patterns, which match in source order — first match wins — and class patterns useisinstancesemantics, so aHomeAssistantErrorwill still match theHomeAssistantErrorcase before it could match a genericExceptioncase. - Ordering is preserved exactly:
CodeSlot→HomeAssistantError(warning) →Exception(error) →BaseException(re-raise). TheBaseExceptionarm must remain last in the source so thatExceptionsubclasses are caught by the more specificExceptioncase; this matches the priorelifordering and the prior control-flow guarantee thatBaseExceptionpropagates only afterExceptionhas had its chance to claim it. matchhere is genuinely the right tool: thecase BaseException(): raise resform is more self-documenting than the originalelif isinstance(res, BaseException): raise res, and avoids a common reader-pitfall where someone "fixes" theraise restoraise(which would lose the original exception).asyncio.gather(..., return_exceptions=True)is unchanged; results are still drained in lockstep withzip(missing_slots, results, strict=False), so cancellation/reordering semantics are untouched.
- The per-result
Regression tests
tests/test_coordinator.pytest_kmlocks_to_dict_preserves_ordered_field_serializationexercises every branch the helper split could have perturbed in one go:datetime→ ISOstr,time→ ISOstr, nested-dataclass list items → recursed dicts, plain list items preserved, nested-dataclass dict values → recursed dicts, plain dict values preserved, and scalar passthrough. The test correctly assertsisinstance(result["timestamp"], str)so a future regression that leaves thedatetimeun-converted would fail.
tests/providers/test_zigbee2mqtt.pytest_get_usercodes_preserves_ordered_query_result_handlingfeeds a six-elementside_effectcovering success, expected error, unexpected error, and a re-raise path, then asserts both cache state and log-level routing. The test usesrecord.getMessage()(notrecord.message), which is robust againstFormattermutating the renderedmsgattribute — a nit from the prior review round has been addressed. Log messages are filtered bylevelnorather than substring matching alone, so a regression that flipswarning↔errorforHomeAssistantErrorwould still trip the level-based assertion.
Architectural / Defensive Notes (non-blocking)
aislop/python-chained-dict-getremains at 2 findings per the PR body. That is a separate, pre-existing class ofaislopfinding and is explicitly out of scope for this refactor — the PR is scoped toisinstanceladders only. No action required.- The
_handle_usercode_query_resultsignature usesCodeSlot | BaseExceptionas a union; this is accurate (a successful result is aCodeSlot, and a failure result is aBaseExceptionbecauseasyncio.gather(..., return_exceptions=True)returns the fullBaseExceptiontree), but a future maintainer might appreciate aTypeAliasliketype _SlotResult = CodeSlot | BaseExceptionif this is reused. Not a blocker. - The
matchblock does not include a defaultcase _:arm. That is correct here because the type union is exhaustive (CodeSlot | BaseExceptioncovers every valuegather(return_exceptions=True)can yield), and an unhandled arm would silently swallowKeyboardInterrupt/SystemExit— aBaseExceptionarm must re-raise, not pass. No change recommended.
✅ Approval Rationale
- All 7 CI checks pass —
Pytest (3.14),coverage,Prek,HACS,Hassfest, and the twoAutolabel PRruns (one benign skip). - Ordering semantics are preserved — both refactors map the original sequential
if/elifchain onto helpers that run the same checks in the same order against the samefield_valuereference (coordinator) and onto amatchblock whose class-pattern source order is documented to be "first match wins" withisinstancesemantics (Z2M provider). - Regression tests cover the ordering — the coordinator test exercises temporal, list, dict, and scalar paths in one fixture; the Z2M test exercises the warning vs error vs re-raise branching, and uses
record.getMessage()(the prior nit is addressed). - No new async / event-loop hazards — neither refactor touches the event loop, executor boundaries, or signal/cleanup paths.
asyncio.gather(return_exceptions=True)is preserved verbatim. - No new dict-key traps — the refactor works on
field_valuereferences that the loop already extracted viagetattr; the new helpers do not introduce new key lookups against upstream payloads. - No defensive-data concerns — this is an in-process serializer; it does not consume external API data.
No inline comments are necessary for an approved refactor of this nature.
Summary
Refactors the two ordered type-dispatch sites reported by
ai-slop/python-isinstance-ladderwithout changing observable behavior.Closes #656.
Proposed change
Coordinator
_kmlocks_to_dictbefore:datetimewas converted first, then the resulting value continued into the separatetime/list/dict/ scalar chain.Coordinator
_kmlocks_to_dictafter:_kmlock_field_to_dict()._kmlock_temporal_value_to_dict()and still uses the same two sequential checks, so adatetimeis converted to an ISO string before collection/scalar handling sees it._kmlock_collection_value_to_dict()with the same recursive behavior as before.Zigbee2MQTT
async_get_usercodesbefore:CodeSlot,HomeAssistantError,Exception, thenBaseExceptionchecks.Zigbee2MQTT
async_get_usercodesafter:_handle_usercode_query_result().matchclass patterns keep the same most-specific-first order:HomeAssistantErroris matched before genericException, andBaseExceptionremains last and is re-raised.Ordering subtleties preserved:
ifreassignment is preserved by running temporal conversion first, then collection/scalar conversion. Adatetimestill becomes an ISO string and is not treated astime,list, ordict.HomeAssistantErrortakes the warning path, genericExceptiontakes the error path, andBaseExceptionpropagates.Validation:
tox -e py314: 1186 passed, 3 skipped, 1 deselected; coverage 95.02%.tox -e lint: passed.aislopscan: total findings 34 → 32;ai-slop/python-isinstance-ladder2 → 0;ai-slop/python-chained-dict-getremains 2.Type of change
Additional information