Skip to content

Commit 34e2847

Browse files
refactor(c901): fix backup subsystem (3 files) below C901 threshold, remove from grandfather list (#1937)
* refactor(c901): backup subsystem below C901 threshold, remove from grandfather list Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DxGQhYBQg7681uCdr9w9qB * fix(c901-b2): drop now-unreachable terminal return, allowlist NoReturn mixed-returns FPs Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DxGQhYBQg7681uCdr9w9qB --------- Co-authored-by: kingpanther13 <kingpanther13@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 45fbcb3 commit 34e2847

5 files changed

Lines changed: 1206 additions & 795 deletions

File tree

pyproject.toml

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -174,9 +174,6 @@ ignore = [
174174
"homeassistant-addon-webhook-proxy/mcp_proxy/__init__.py" = ["C901"]
175175
"homeassistant-addon-webhook-proxy/start.py" = ["C901"]
176176
"scripts/extract_tools.py" = ["C901"]
177-
"src/ha_mcp/backup_manager.py" = ["C901"]
178-
"src/ha_mcp/tools/auto_backup.py" = ["C901"]
179-
"src/ha_mcp/tools/backup.py" = ["C901"]
180177
"src/ha_mcp/tools/tools_config_automations.py" = ["C901"]
181178
"src/ha_mcp/tools/tools_config_scenes.py" = ["C901"]
182179
"src/ha_mcp/tools/tools_config_scripts.py" = ["C901"]

scripts/codeql_quality_gate.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,20 @@
163163
"heuristic to have correctly caught. mypy already confirms this file is "
164164
"clean under that typing.",
165165
),
166+
# NOTE: same PATH-WIDE caveat as the server.py entry above — re-audit if
167+
# backup.py grows genuinely mixed-returns functions.
168+
(
169+
"py/mixed-returns",
170+
"src/ha_mcp/tools/backup.py",
171+
"Mixing implicit and explicit returns",
172+
"False positive on _finalize_backup_timeout and _perform_restore's "
173+
"success-branch-vs-raise shape (helpers extracted from the C901 "
174+
"refactor, issue #925): the non-return branch in each always goes "
175+
"through raise_tool_error, typed '-> NoReturn' (see helpers.py), so "
176+
"there is no implicit None return for CodeQL's heuristic to have "
177+
"correctly caught. mypy already confirms this file is clean under "
178+
"that typing.",
179+
),
166180
# ---- Security-suite entries (the workflow also gates the default
167181
# <language>-code-scanning suites; see codeql-quality.yml header). Entries
168182
# whose message substring is generic ("as clear text", "hashing algorithm")

src/ha_mcp/backup_manager.py

Lines changed: 200 additions & 116 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,13 @@ def _resolve_default_dir() -> Path:
195195
return (Path.home() / ".local" / "share" / "ha_mcp" / "backups").resolve()
196196

197197

198+
# Sentinel returned by ``BackupManager._fetch_config_for_snapshot`` when there
199+
# is nothing to snapshot — a handled transient fetch failure (already logged),
200+
# or a None config for an entity that did not exist. Distinct from any real
201+
# config value so the caller can tell "skip" from a genuine payload.
202+
_SNAPSHOT_SKIP: Any = object()
203+
204+
198205
class BackupManager:
199206
"""Per-entity snapshot manager. One instance per server, cached on client."""
200207

@@ -317,6 +324,48 @@ async def maybe_snapshot(
317324
apply (force can't conjure a snapshot for an entity that
318325
doesn't exist or has no registered handler).
319326
"""
327+
handler = self._resolve_snapshot_handler(
328+
domain, entity_id, force=force, mandatory=mandatory
329+
)
330+
if handler is None:
331+
return None
332+
333+
key = f"{domain}:{entity_id}"
334+
lock = self._locks.setdefault(key, asyncio.Lock())
335+
async with lock:
336+
now = time.monotonic()
337+
throttle = self.throttle_seconds
338+
# Skip throttle if no prior snapshot exists for this key.
339+
# Using ``get(key, 0.0)`` would falsely block the first capture
340+
# whenever ``monotonic()`` < throttle (typical on a fresh process
341+
# in CI), since 0.0 would be treated as "last snapshot at
342+
# monotonic time 0".
343+
if (
344+
not force
345+
and throttle
346+
and key in self._last_snapshot
347+
and (now - self._last_snapshot[key]) < throttle
348+
):
349+
return None
350+
config = await self._fetch_config_for_snapshot(
351+
handler, entity_id, key, mandatory=mandatory
352+
)
353+
if config is _SNAPSHOT_SKIP:
354+
return None
355+
return await self._write_and_rotate(
356+
domain, entity_id, key, config, tool_name, now, mandatory=mandatory
357+
)
358+
359+
def _resolve_snapshot_handler(
360+
self, domain: str, entity_id: str, *, force: bool, mandatory: bool
361+
) -> DomainHandler | None:
362+
"""Run the pre-capture guards; return the handler or None to skip.
363+
364+
Raises ``MandatoryBackupError`` for the fail-closed cases (an unusable
365+
backup dir, an unregistered domain) under ``mandatory``. A None return
366+
is a legitimate skip (feature disabled, no entity id, no handler) — the
367+
caller returns None and lets the wrapped write proceed.
368+
"""
320369
if self._init_dir_error is not None:
321370
if mandatory:
322371
raise MandatoryBackupError(
@@ -344,88 +393,101 @@ async def maybe_snapshot(
344393
domain,
345394
)
346395
return None
396+
return handler
347397

348-
key = f"{domain}:{entity_id}"
349-
lock = self._locks.setdefault(key, asyncio.Lock())
350-
async with lock:
351-
now = time.monotonic()
352-
throttle = self.throttle_seconds
353-
# Skip throttle if no prior snapshot exists for this key.
354-
# Using ``get(key, 0.0)`` would falsely block the first capture
355-
# whenever ``monotonic()`` < throttle (typical on a fresh process
356-
# in CI), since 0.0 would be treated as "last snapshot at
357-
# monotonic time 0".
358-
if (
359-
not force
360-
and throttle
361-
and key in self._last_snapshot
362-
and (now - self._last_snapshot[key]) < throttle
363-
):
364-
return None
365-
try:
366-
config = await handler.fetch(self._client, entity_id)
367-
except _CAPTURE_TRANSIENT_ERRORS as err:
368-
# Degraded fetches (a non-list WS envelope from an
369-
# auth-scope change or API drift) raise rather than return
370-
# None — see ``_require_list``. During auto-backup we skip
371-
# the snapshot with a WARNING (operator-visible) instead of
372-
# crashing the pipeline; the same error during a diff/
373-
# restore propagates to the tool layer as a structured
374-
# error. The warning level (vs the debug log below) is what
375-
# distinguishes "fetch broke" from "entity didn't exist".
376-
if mandatory:
377-
raise MandatoryBackupError(
378-
f"could not read the current state of {key} to back "
379-
f"it up: {type(err).__name__}: {err}"
380-
) from err
381-
logger.warning(
382-
"Auto-backup: fetch failed for %s — %s: %s",
383-
key,
384-
type(err).__name__,
385-
err,
386-
)
387-
return None
388-
if config is None:
389-
# Entity didn't exist at fetch time (create operation, or
390-
# already-deleted at remove time before our pre-fetch).
391-
logger.debug(
392-
"Auto-backup: fetch returned None for %s — skipping snapshot",
393-
key,
394-
)
395-
return None
396-
try:
397-
path = await asyncio.to_thread(
398-
self._write_snapshot, domain, entity_id, config, tool_name
399-
)
400-
except (OSError, yaml.YAMLError) as err:
401-
if mandatory:
402-
raise MandatoryBackupError(
403-
f"could not write the pre-write snapshot for {key}: "
404-
f"{type(err).__name__}: {err}",
405-
suggestions=[
406-
"Free up disk space, or delete old snapshots via "
407-
"ha_manage_backup(scope='edits', action='delete')",
408-
],
409-
) from err
410-
logger.warning(
411-
"Auto-backup: write failed for %s — %s: %s",
412-
key,
413-
type(err).__name__,
414-
err,
415-
)
416-
return None
417-
self._last_snapshot[key] = now
418-
self._maybe_prune_trackers()
419-
try:
420-
await asyncio.to_thread(self._rotate, domain, entity_id)
421-
except OSError as err:
422-
logger.warning(
423-
"Auto-backup: rotation failed for %s — %s: %s",
424-
key,
425-
type(err).__name__,
426-
err,
427-
)
428-
return path
398+
async def _fetch_config_for_snapshot(
399+
self, handler: DomainHandler, entity_id: str, key: str, *, mandatory: bool
400+
) -> Any:
401+
"""Fetch the pre-write config; return ``_SNAPSHOT_SKIP`` to skip capture.
402+
403+
A handled transient fetch failure logs a WARNING and returns the
404+
sentinel (or raises ``MandatoryBackupError`` under ``mandatory``); a
405+
fetch that returns None because the entity did not exist logs a DEBUG
406+
and returns the sentinel. Any other value is the config to snapshot.
407+
"""
408+
try:
409+
config = await handler.fetch(self._client, entity_id)
410+
except _CAPTURE_TRANSIENT_ERRORS as err:
411+
# Degraded fetches (a non-list WS envelope from an
412+
# auth-scope change or API drift) raise rather than return
413+
# None — see ``_require_list``. During auto-backup we skip
414+
# the snapshot with a WARNING (operator-visible) instead of
415+
# crashing the pipeline; the same error during a diff/
416+
# restore propagates to the tool layer as a structured
417+
# error. The warning level (vs the debug log below) is what
418+
# distinguishes "fetch broke" from "entity didn't exist".
419+
if mandatory:
420+
raise MandatoryBackupError(
421+
f"could not read the current state of {key} to back "
422+
f"it up: {type(err).__name__}: {err}"
423+
) from err
424+
logger.warning(
425+
"Auto-backup: fetch failed for %s — %s: %s",
426+
key,
427+
type(err).__name__,
428+
err,
429+
)
430+
return _SNAPSHOT_SKIP
431+
if config is None:
432+
# Entity didn't exist at fetch time (create operation, or
433+
# already-deleted at remove time before our pre-fetch).
434+
logger.debug(
435+
"Auto-backup: fetch returned None for %s — skipping snapshot",
436+
key,
437+
)
438+
return _SNAPSHOT_SKIP
439+
return config
440+
441+
async def _write_and_rotate(
442+
self,
443+
domain: str,
444+
entity_id: str,
445+
key: str,
446+
config: Any,
447+
tool_name: str | None,
448+
now: float,
449+
*,
450+
mandatory: bool,
451+
) -> Path | None:
452+
"""Write the snapshot then rotate old files; return the written Path.
453+
454+
Returns None on a handled (non-mandatory) write failure. Raises
455+
``MandatoryBackupError`` under ``mandatory`` when the write genuinely
456+
fails (e.g. disk-full).
457+
"""
458+
try:
459+
path = await asyncio.to_thread(
460+
self._write_snapshot, domain, entity_id, config, tool_name
461+
)
462+
except (OSError, yaml.YAMLError) as err:
463+
if mandatory:
464+
raise MandatoryBackupError(
465+
f"could not write the pre-write snapshot for {key}: "
466+
f"{type(err).__name__}: {err}",
467+
suggestions=[
468+
"Free up disk space, or delete old snapshots via "
469+
"ha_manage_backup(scope='edits', action='delete')",
470+
],
471+
) from err
472+
logger.warning(
473+
"Auto-backup: write failed for %s — %s: %s",
474+
key,
475+
type(err).__name__,
476+
err,
477+
)
478+
return None
479+
self._last_snapshot[key] = now
480+
self._maybe_prune_trackers()
481+
try:
482+
await asyncio.to_thread(self._rotate, domain, entity_id)
483+
except OSError as err:
484+
logger.warning(
485+
"Auto-backup: rotation failed for %s — %s: %s",
486+
key,
487+
type(err).__name__,
488+
err,
489+
)
490+
return path
429491

430492
def _maybe_prune_trackers(self) -> None:
431493
"""Cap per-entity tracker growth.
@@ -1024,44 +1086,11 @@ def _diff_node(
10241086
if type(stored) is type(current):
10251087
if isinstance(stored, dict):
10261088
assert isinstance(current, dict)
1027-
for key in stored:
1028-
seg = _pointer_segment(str(key))
1029-
sub_path = f"{path}/{seg}"
1030-
if key not in current:
1031-
out.append({"op": "add", "path": sub_path, "value": stored[key]})
1032-
if len(out) >= max_ops:
1033-
return
1034-
else:
1035-
_diff_node(stored[key], current[key], sub_path, out, max_ops)
1036-
if len(out) >= max_ops:
1037-
return
1038-
for key in current:
1039-
if key not in stored:
1040-
seg = _pointer_segment(str(key))
1041-
out.append({"op": "remove", "path": f"{path}/{seg}"})
1042-
if len(out) >= max_ops:
1043-
return
1089+
_diff_dict_node(stored, current, path, out, max_ops)
10441090
return
10451091
if isinstance(stored, list):
10461092
assert isinstance(current, list)
1047-
min_len = min(len(stored), len(current))
1048-
for i in range(min_len):
1049-
_diff_node(stored[i], current[i], f"{path}/{i}", out, max_ops)
1050-
if len(out) >= max_ops:
1051-
return
1052-
if len(stored) > len(current):
1053-
for value in stored[len(current) :]:
1054-
out.append({"op": "add", "path": f"{path}/-", "value": value})
1055-
if len(out) >= max_ops:
1056-
return
1057-
elif len(current) > len(stored):
1058-
# Remove tail entries from highest to lowest index so
1059-
# successive removes stay valid (RFC 6902 reindexes
1060-
# after each op).
1061-
for i in range(len(current) - 1, len(stored) - 1, -1):
1062-
out.append({"op": "remove", "path": f"{path}/{i}"})
1063-
if len(out) >= max_ops:
1064-
return
1093+
_diff_list_node(stored, current, path, out, max_ops)
10651094
return
10661095
if stored != current:
10671096
out.append({"op": "replace", "path": path or "", "value": stored})
@@ -1076,6 +1105,61 @@ def _diff_node(
10761105
out.append({"op": "replace", "path": path or "", "value": stored})
10771106

10781107

1108+
def _diff_dict_node(
1109+
stored: dict[Any, Any],
1110+
current: dict[Any, Any],
1111+
path: str,
1112+
out: list[dict[str, Any]],
1113+
max_ops: int,
1114+
) -> None:
1115+
"""Diff two dicts into JSON-Patch ops (add/remove/recurse per key)."""
1116+
for key in stored:
1117+
seg = _pointer_segment(str(key))
1118+
sub_path = f"{path}/{seg}"
1119+
if key not in current:
1120+
out.append({"op": "add", "path": sub_path, "value": stored[key]})
1121+
if len(out) >= max_ops:
1122+
return
1123+
else:
1124+
_diff_node(stored[key], current[key], sub_path, out, max_ops)
1125+
if len(out) >= max_ops:
1126+
return
1127+
for key in current:
1128+
if key not in stored:
1129+
seg = _pointer_segment(str(key))
1130+
out.append({"op": "remove", "path": f"{path}/{seg}"})
1131+
if len(out) >= max_ops:
1132+
return
1133+
1134+
1135+
def _diff_list_node(
1136+
stored: list[Any],
1137+
current: list[Any],
1138+
path: str,
1139+
out: list[dict[str, Any]],
1140+
max_ops: int,
1141+
) -> None:
1142+
"""Diff two lists into JSON-Patch ops (positional recurse, then tail add/remove)."""
1143+
min_len = min(len(stored), len(current))
1144+
for i in range(min_len):
1145+
_diff_node(stored[i], current[i], f"{path}/{i}", out, max_ops)
1146+
if len(out) >= max_ops:
1147+
return
1148+
if len(stored) > len(current):
1149+
for value in stored[len(current) :]:
1150+
out.append({"op": "add", "path": f"{path}/-", "value": value})
1151+
if len(out) >= max_ops:
1152+
return
1153+
elif len(current) > len(stored):
1154+
# Remove tail entries from highest to lowest index so
1155+
# successive removes stay valid (RFC 6902 reindexes
1156+
# after each op).
1157+
for i in range(len(current) - 1, len(stored) - 1, -1):
1158+
out.append({"op": "remove", "path": f"{path}/{i}"})
1159+
if len(out) >= max_ops:
1160+
return
1161+
1162+
10791163
def _pointer_segment(key: str) -> str:
10801164
"""Escape one JSON-Pointer reference token per RFC 6901 §3.
10811165

0 commit comments

Comments
 (0)