@@ -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+
198205class 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+
10791163def _pointer_segment (key : str ) -> str :
10801164 """Escape one JSON-Pointer reference token per RFC 6901 §3.
10811165
0 commit comments