Skip to content

Commit 6cdb9ad

Browse files
authored
refactor: validate only new entries on convenience-mode writes (#1086) (#1100)
* refactor: validate only new entries on convenience-mode writes (#1086) Convenience-mode writes (_add_device, _add_source, _remove_device) reach _set_prefs through _mutate_atomic, which builds partial_config equal to the FULL existing list ± one entry. _set_prefs ran _shape_check on that union — re-validating pre-existing (HA-validated) siblings on every add/remove. If _shape_check is ever tightened past HA's voluptuous schema, an unrelated add would fail because a SIBLING entry would suddenly fail the local check. Scope _shape_check on the convenience-mode write path to the appended tail only: - _shape_check accepts validate_only: dict[str, set[int]] | None. None preserves the original full-validation contract. An empty dict skips the per-entry pass entirely. Structural "must be a dict" / "must be a list" checks still fire for listed keys. - _set_prefs forwards validate_only as a kwarg-only param. - _mutate_atomic computes the appended-tail indices (range(existing_count, new_count)) and passes them on both the real-run write and the dry-run backstop. For add_* this is the new entry only; for remove_* this is set() so nothing is re-validated. - Direct mode='set' callers unchanged (validate_only defaults to None). Assumes append-only/remove-only mutator semantics; revisit for any future in-place mutator. No reachable bug today (the local check is currently a strict subset of HA's voluptuous schema). Forward-looking robustness — see issue body. Closes #1086 * docs(energy): clarify {key: set()} vs {} semantics in _shape_check Gemini G1 (PR #1100, medium): the prior docstring conflated the two empty-validate_only forms. _mutate_atomic for remove operations passes {target_key: set()} (preserves the structural "must be a list" check on target_key, skips its per-entry pass), not {} (which would skip all keys including the structural check). Update the docstring to reflect both shapes accurately. No code-behaviour change; pytest 1463/1 grün, mypy clean. * docs(energy): start helper docstrings with action verbs Gemini G2 (PR #1100, medium): suggested starting _shape_check's docstring with an action verb. Empirical norm in tools_energy.py is 11/15 internal helpers action-verb-first; spot-check tools_addons.py and tools_integrations.py shows the same pattern across the project. Three noun-phrase outliers in this file aligned for consistency: - _shape_check: "Cheap" -> "Perform a cheap" - _is_no_prefs_error: "True if" -> "Return True if" - _mutate_atomic: "Read-modify-write loop" -> "Run the read-modify-write loop" Note: the .gemini/styleguide.md rule itself is scoped to public @tool docstrings (verb whitelist Get/List/Search/Create/Update/Delete/Remove/ Execute/Call/Manage), so adoption here is empirical-pattern-consistency rather than literal styleguide-compliance. No code-behaviour change; pytest 1463/1, mypy + ruff clean. * fix: remove leftover merge-conflict markers in tools_energy docstring The post-#1098 rebase left three conflict markers (<<<<<<< HEAD, =======, >>>>>>> 0825a15) inside the _set_prefs docstring at lines 677/680/689. They survived ruff, mypy, AST parsing, and the full unit suite because they sat inside a triple-quoted string literal — valid Python content, no static-analysis trigger. Resolved by merging the two halves into a single coherent paragraph covering both the str/dict config_hash form contract and the validate_only forwarding semantics. No behaviour change; pytest 1479/1, mypy + ruff still clean. * docs(energy): tighten helper docstrings and heuristic warnings Self-Review Boy-Scout sweep on the lines this PR already touches: - _shape_check first line: "Perform a cheap local shape check..." had redundant "cheap" + "shape check" wording; replaced with "Validate config shape locally..." (action-verb-first, drops the redundancy). Note: ".gemini/styleguide.md" verb whitelist is scoped to public @tool docstrings, not internal helpers — so neither "Perform" nor "Validate" sit on the whitelist; this rewording is empirical-pattern- consistency only. - _mutate_atomic docstring lead: was a one-liner "Run the read-modify- write loop for convenience modes."; expanded to "Run convenience-mode read-modify-write with dry-run backstop and hash-conflict retry." to match what the function actually owns (per body L1313-1336). - Heuristic warning at the appended_indices computation (real-run + dry-run): made the in-place-mutator failure mode explicit. An in-place mutator with len(new) == len(existing) would yield an empty appended_indices and silently skip the per-entry pass entirely. Dropping just "revisit if added" was too oblique. No code-behaviour change; pytest 1479/1, mypy + ruff clean. * refactor(energy): extract _appended_tail_indices helper; harden invariants Addresses kingpanther13's #1100 review: Requested: 1. Convert the in-place-mutator assumption into a runtime guard. The appended-tail formula yields an empty index set when len(new) == len(existing) regardless of content, which would silently bypass per-entry validation if a future _replace_*/_update_* mutator were added. The new `_appended_tail_indices` helper raises an INTERNAL_ERROR when same-length non-equal mutation is detected, so the wrong shape fails loudly at test time rather than silently in production. 2. Replace the bare AssertionError in the unreachable retry-loop exit with a structured raise_tool_error(INTERNAL_ERROR), matching the pattern used elsewhere in this file. The previous AssertionError fell through `except Exception` and surfaced as a context-less generic INTERNAL_ERROR. While you're in there: 3. Extract the duplicated 'append-only/shrink-only' rationale and index computation from `_mutate_atomic` (dry_run + real-run branches) into the new module-level helper. Both call sites now share one source of truth; the rationale lives in the helper docstring and the call-site comments collapse to a one-line reference. 4. Rename test_add_device_still_rejects_a_genuinely_invalid_new_entry to test_direct_set_mode_still_validates_full_config_when_validate_only_is_none so the boundary it pins is in the name (it actually exercises mode='set' with the default validate_only=None, not add_device). 5. Add a direct `_shape_check` test for validate_only={key: set()} pinning the docstring's distinction between {} (skip everything, structural list-check skipped for unlisted keys) and {key: set()} (key listed, list-shape still checked, per-entry skipped). The latter is what `_remove_*` mutators emit via _appended_tail_indices. 6. Parametrize test_*_dry_run_succeeds_with_invalid_pre_existing across add_device / add_source / remove_device. The dry_run path in _mutate_atomic is shared but parametrizing locks symmetry with the real-run regression block above, so future divergence between the two _mutate_atomic branches breaks loudly. All 101 tests in test_tools_energy.py pass; ruff and mypy clean.
1 parent 0e9b18d commit 6cdb9ad

2 files changed

Lines changed: 406 additions & 13 deletions

File tree

src/ha_mcp/tools/tools_energy.py

Lines changed: 108 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ def _compute_per_key_hashes(prefs: dict[str, Any]) -> dict[_PrefsKey, str]:
9696

9797

9898
def _is_no_prefs_error(error_msg: str) -> bool:
99-
"""True if an error string from send_websocket_message indicates
99+
"""Return True if an error string from send_websocket_message indicates
100100
``ERR_NOT_FOUND "No prefs"`` from HA Core's energy/get_prefs handler.
101101
102102
HA Core wraps the error as ``f"Command failed: {message}"``; the
@@ -143,13 +143,30 @@ def _flatten_validation_errors(raw: Any) -> list[dict[str, str]]:
143143
return errors
144144

145145

146-
def _shape_check(config: dict[str, Any]) -> list[dict[str, str]]:
147-
"""Cheap local shape check before sending to the server.
146+
def _shape_check(
147+
config: dict[str, Any],
148+
validate_only: dict[str, set[int]] | None = None,
149+
) -> list[dict[str, str]]:
150+
"""Validate config shape locally before sending to the server.
148151
149152
Validates that top-level keys have the expected list-of-dicts shape and
150153
that required identifying fields are present. Does NOT validate semantic
151154
correctness (stat IDs existing, units matching, etc.) — that's surfaced
152155
by the post-save server-side ``energy/validate`` call.
156+
157+
``validate_only`` scopes the per-entry check. ``None`` (default) validates
158+
every entry under every present top-level key — the original contract.
159+
A dict scopes the check to the listed keys and, within each, only the
160+
listed indices: top-level keys absent from the dict are skipped entirely,
161+
indices outside each key's set are skipped per-entry. The "must be a
162+
list" structural check still fires for any present-and-listed key with a
163+
non-list value, so ``validate_only`` cannot be used to bypass structural
164+
sanity. A dict with an empty set for a key (``{key: set()}``) skips the
165+
per-entry pass for that key while preserving the structural check —
166+
this is what convenience-mode write paths pass for remove operations
167+
(no new entries to validate, but the list shape is still checked). An
168+
empty dict (``{}``) skips all keys entirely. See issue #1086 for the
169+
asymmetric over-validation problem this addresses.
153170
"""
154171
errors: list[dict[str, str]] = []
155172

@@ -159,11 +176,18 @@ def _shape_check(config: dict[str, Any]) -> list[dict[str, str]]:
159176
for key in _PREFS_TOP_LEVEL_KEYS:
160177
if key not in config:
161178
continue
179+
if validate_only is not None and key not in validate_only:
180+
continue
162181
value = config[key]
163182
if not isinstance(value, list):
164183
errors.append({"path": key, "message": "must be a list"})
165184
continue
185+
allowed_indices: set[int] | None = (
186+
validate_only[key] if validate_only is not None else None
187+
)
166188
for idx, entry in enumerate(value):
189+
if allowed_indices is not None and idx not in allowed_indices:
190+
continue
167191
if not isinstance(entry, dict):
168192
errors.append(
169193
{
@@ -217,6 +241,47 @@ def _shape_check(config: dict[str, Any]) -> list[dict[str, str]]:
217241
return errors
218242

219243

244+
def _appended_tail_indices(existing: list[Any], new: list[Any]) -> set[int]:
245+
"""Return the indices in ``new`` that lie past the end of ``existing``.
246+
247+
Per issue #1086, this builds the ``validate_only`` index set scoped to the
248+
appended tail of an append-only / shrink-only mutation, so pre-existing
249+
HA-validated siblings are not re-checked on every add/remove:
250+
251+
- Append-only mutators (``_add_*``): returns indices of the new entries.
252+
- Shrink-only mutators (``_remove_*``): returns an empty set — nothing
253+
new to validate, and the surviving entries already passed HA validation.
254+
255+
Refuses in-place mutators where ``len(new) == len(existing)`` but the
256+
contents differ — the appended-tail formula would yield an empty index
257+
set on a list whose entries actually changed, silently bypassing
258+
per-entry validation. Add explicit handling (e.g. an
259+
``_indices_of_modified_entries`` helper) before introducing such a
260+
mutator; do not extend this one.
261+
"""
262+
if len(new) == len(existing) and new != existing:
263+
raise_tool_error(
264+
create_error_response(
265+
ErrorCode.INTERNAL_ERROR,
266+
"_appended_tail_indices: in-place mutation detected "
267+
"(same length, different content) — the appended-tail "
268+
"validation heuristic only covers append-only / shrink-only "
269+
"mutators. Add explicit per-entry validation handling for "
270+
"in-place mutators before reusing this helper.",
271+
context={
272+
"existing_len": len(existing),
273+
"new_len": len(new),
274+
},
275+
suggestions=[
276+
"If introducing a _replace_* / _update_* mutator, "
277+
"compute the indices of modified entries explicitly "
278+
"and pass those to _shape_check via validate_only.",
279+
],
280+
)
281+
)
282+
return set(range(len(existing), len(new)))
283+
284+
220285
class EnergyTools:
221286
"""Energy Dashboard preference management tools for Home Assistant."""
222287

@@ -629,6 +694,7 @@ async def _set_prefs(
629694
config_hash: str | dict[_PrefsKey, str],
630695
*,
631696
current_prefs: dict[str, Any] | None = None,
697+
validate_only: dict[str, set[int]] | None = None,
632698
) -> dict[str, Any]:
633699
"""Shape-check → hash-check → save → post-save validate.
634700
@@ -650,11 +716,18 @@ async def _set_prefs(
650716
path uses this to avoid a second ``energy/get_prefs`` round trip
651717
per attempt (the snapshot was already fetched by ``_mutate_atomic``).
652718
Convenience modes always pass a ``str`` hash; the dict form is
653-
only reachable via direct mode='set' callers.
719+
only reachable via direct mode='set' callers. The hash check still
720+
runs against the provided snapshot as a defensive guard.
721+
722+
``validate_only`` is forwarded to ``_shape_check`` and lets a caller
723+
scope the per-entry check to specific top-level keys / indices.
724+
Convenience-mode writes pass the appended tail indices so
725+
pre-existing (HA-validated) entries are not re-validated against the
726+
local schema — see issue #1086.
654727
"""
655728
try:
656729
# 1. Shape check (fast local, fail closed)
657-
shape_errors = _shape_check(config)
730+
shape_errors = _shape_check(config, validate_only=validate_only)
658731
if shape_errors:
659732
raise_tool_error(
660733
create_error_response(
@@ -1176,7 +1249,7 @@ async def _mutate_atomic(
11761249
dry_run: bool,
11771250
preview_payload: dict[str, Any],
11781251
) -> dict[str, Any]:
1179-
"""Read-modify-write loop for convenience modes.
1252+
"""Run convenience-mode read-modify-write with dry-run backstop and hash-conflict retry.
11801253
11811254
Atomicity is with respect to the *entire* prefs snapshot, not just
11821255
``target_key``: ``_set_prefs`` validates the full ``config_hash``, so
@@ -1205,9 +1278,14 @@ async def _mutate_atomic(
12051278
new_list = mutator(existing_list)
12061279

12071280
# Backstop shape-check, mirroring the real-run path through
1208-
# ``_set_prefs`` — keeps dry_run/real-run shape-equivalent
1209-
# if the entry-construction logic ever changes.
1210-
shape_errors = _shape_check({target_key: new_list})
1281+
# ``_set_prefs`` — keeps dry_run/real-run shape-equivalent if
1282+
# the entry-construction logic ever changes. See
1283+
# ``_appended_tail_indices`` for the validate_only contract.
1284+
appended_indices = _appended_tail_indices(existing_list, new_list)
1285+
shape_errors = _shape_check(
1286+
{target_key: new_list},
1287+
validate_only={target_key: appended_indices},
1288+
)
12111289
if shape_errors:
12121290
raise_tool_error(
12131291
create_error_response(
@@ -1241,11 +1319,17 @@ async def _mutate_atomic(
12411319
new_list = mutator(existing_list)
12421320

12431321
partial_config = {target_key: new_list}
1322+
# Per issue #1086: validate only the appended tail so a
1323+
# pre-existing HA-validated entry cannot block an unrelated
1324+
# add/remove. See ``_appended_tail_indices`` for the
1325+
# validate_only contract.
1326+
appended_indices = _appended_tail_indices(existing_list, new_list)
12441327
try:
12451328
set_result = await self._set_prefs(
12461329
partial_config,
12471330
current_hash,
12481331
current_prefs=current_config,
1332+
validate_only={target_key: appended_indices},
12491333
)
12501334
except ToolError as exc:
12511335
# _set_prefs raises ToolError(RESOURCE_LOCKED) on hash mismatch.
@@ -1289,10 +1373,21 @@ async def _mutate_atomic(
12891373
# Unreachable as long as every iteration either returns or raises:
12901374
# the only ``continue`` is gated on ``attempt + 1 < max_attempts``,
12911375
# which is False on the final iteration — so the bare ``raise``
1292-
# in the except block always fires there.
1293-
raise AssertionError(
1294-
f"_mutate_atomic({mode}, {target_key}): "
1295-
"retry loop exited without a return or raise"
1376+
# in the except block always fires there. Surface as an actionable
1377+
# structured error rather than a bare AssertionError that would
1378+
# otherwise fall through to ``except Exception`` and lose context.
1379+
raise_tool_error(
1380+
create_error_response(
1381+
ErrorCode.INTERNAL_ERROR,
1382+
f"_mutate_atomic({mode}, {target_key}): retry loop exited "
1383+
"without a return or raise",
1384+
context={"mode": mode, "target_key": target_key},
1385+
suggestions=[
1386+
"This indicates a bug in the optimistic-concurrency "
1387+
"loop logic — please file an issue with the mode and "
1388+
"target_key from the context.",
1389+
],
1390+
)
12961391
)
12971392

12981393
except ToolError:

0 commit comments

Comments
 (0)