Coverage for src/objectstate/transaction_checkpoint.py: 99%

86 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-17 07:22 +0000

1"""Exact local rollback boundary for an ObjectState transaction participant.""" 

2 

3from __future__ import annotations 

4 

5import copy 

6from dataclasses import dataclass 

7from typing import Any, TYPE_CHECKING 

8 

9from objectstate.construction_binding import StateConstructionBinding 

10from objectstate.object_state_metadata import ObjectStateMetadataStore 

11from objectstate.parameter_structure import ParameterStructure 

12from objectstate.value_semantics import semantic_values_equal 

13 

14if TYPE_CHECKING: 

15 from objectstate.object_state import ObjectState 

16 

17 

18@dataclass(frozen=True, slots=True) 

19class ObjectStateTransactionCheckpoint: 

20 """Capture and restore one ObjectState without changing its edit semantics. 

21 

22 This is intentionally distinct from the serializable time-travel snapshot: 

23 a local transaction checkpoint retains the exact saved object and delegate 

24 identities as well as live unsaved edits and ephemeral reconstruction 

25 caches. 

26 """ 

27 

28 object_instance: Any 

29 construction: StateConstructionBinding 

30 parent_state: ObjectState | None 

31 structure: ParameterStructure 

32 preserve_value_identity: bool 

33 parameters: dict[str, Any] 

34 saved_parameters: dict[str, Any] 

35 live_resolved: dict[str, Any] | None 

36 saved_resolved: dict[str, Any] 

37 live_provenance: dict[str, Any] 

38 metadata: dict[str, Any] 

39 cached_object: Any 

40 cached_object_applied: bool 

41 invalid_fields: frozenset[str] 

42 resolved_from: dict[bool, Any] 

43 raw_dirty: bool 

44 dirty_fields: frozenset[str] 

45 signature_diff_fields: frozenset[str] 

46 last_changed_field: str | None 

47 last_changed_concrete_paths: frozenset[str] 

48 last_changed_paths: frozenset[str] 

49 last_changed_values: dict[str, Any] 

50 last_changed_meta_keys: frozenset[str] 

51 last_changed_meta_values: dict[str, Any] 

52 

53 @classmethod 

54 def capture( 

55 cls, state: ObjectState, *, preserve_value_identity: bool = False 

56 ) -> "ObjectStateTransactionCheckpoint": 

57 """Capture the exact local state before a fallible transaction.""" 

58 

59 copied = cls._checkpoint_mappings( 

60 state, 

61 { 

62 "parameters": state.parameters, 

63 "saved_parameters": state._saved_parameters, 

64 "live_resolved": state._live_resolved, 

65 "saved_resolved": state._saved_resolved, 

66 "live_provenance": state._live_provenance, 

67 "last_changed_values": state._last_changed_values, 

68 "last_changed_meta_values": state._last_changed_meta_values, 

69 }, 

70 preserve_value_identity=preserve_value_identity, 

71 ) 

72 return cls( 

73 object_instance=state.object_instance, 

74 construction=state.construction_binding(), 

75 parent_state=state._parent_state, 

76 structure=state.parameter_structure.clone(), 

77 preserve_value_identity=preserve_value_identity, 

78 parameters=copied["parameters"], 

79 saved_parameters=copied["saved_parameters"], 

80 live_resolved=copied["live_resolved"], 

81 saved_resolved=copied["saved_resolved"], 

82 live_provenance=copied["live_provenance"], 

83 metadata=state.copy_metadata_for_snapshot(), 

84 cached_object=state._cached_object, 

85 cached_object_applied=state._cached_object_applied, 

86 invalid_fields=frozenset(state._invalid_fields), 

87 resolved_from=dict(state._resolved_from), 

88 raw_dirty=state._raw_dirty, 

89 dirty_fields=frozenset(state._dirty_fields), 

90 signature_diff_fields=frozenset(state._signature_diff_fields), 

91 last_changed_field=state._last_changed_field, 

92 last_changed_concrete_paths=frozenset(state._last_changed_concrete_paths), 

93 last_changed_paths=frozenset(state._last_changed_paths), 

94 last_changed_values=copied["last_changed_values"], 

95 last_changed_meta_keys=frozenset(state._last_changed_meta_keys), 

96 last_changed_meta_values=copied["last_changed_meta_values"], 

97 ) 

98 

99 @property 

100 def extraction_target(self) -> Any: 

101 return self.construction.target 

102 

103 def restore(self, state: ObjectState, *, emit_notifications: bool = True) -> None: 

104 """Restore the captured state and then publish one coherent change.""" 

105 

106 changed_fields = self._changed_fields(state) if emit_notifications else set() 

107 copied = self._checkpoint_mappings( 

108 state, 

109 { 

110 "parameters": self.parameters, 

111 "saved_parameters": self.saved_parameters, 

112 "live_resolved": self.live_resolved, 

113 "saved_resolved": self.saved_resolved, 

114 "live_provenance": self.live_provenance, 

115 "last_changed_values": self.last_changed_values, 

116 "last_changed_meta_values": self.last_changed_meta_values, 

117 }, 

118 preserve_value_identity=self.preserve_value_identity, 

119 ) 

120 

121 state.object_instance = self.object_instance 

122 self.construction.attach(state) 

123 state._parent_state = self.parent_state 

124 state._parent_field_name = self.construction.parent_field 

125 state.parameter_structure = self.structure.clone() 

126 state.parameters = copied["parameters"] 

127 state._index_parameter_paths() 

128 state._saved_parameters = copied["saved_parameters"] 

129 state._live_resolved = copied["live_resolved"] 

130 state._saved_resolved = copied["saved_resolved"] 

131 state._live_provenance = copied["live_provenance"] 

132 state.metadata = ObjectStateMetadataStore.from_snapshot( 

133 scope_id=state.scope_id, 

134 metadata=self.metadata, 

135 ) 

136 state._cached_object = self.cached_object 

137 state._cached_object_applied = self.cached_object_applied 

138 state._invalid_fields = set(self.invalid_fields) 

139 state._resolved_from = dict(self.resolved_from) 

140 state._raw_dirty = self.raw_dirty 

141 state._dirty_fields = set(self.dirty_fields) 

142 state._signature_diff_fields = set(self.signature_diff_fields) 

143 state._last_changed_field = self.last_changed_field 

144 state._last_changed_concrete_paths = set(self.last_changed_concrete_paths) 

145 state._last_changed_paths = set(self.last_changed_paths) 

146 state._last_changed_values = copied["last_changed_values"] 

147 state._last_changed_meta_keys = set(self.last_changed_meta_keys) 

148 state._last_changed_meta_values = copied["last_changed_meta_values"] 

149 

150 if changed_fields: 

151 state._notify_resolved_changed( 

152 changed_fields, 

153 context="transaction_checkpoint_restore", 

154 ) 

155 state._notify_state_changed(changed_fields) 

156 

157 def _changed_fields(self, state: ObjectState) -> set[str]: 

158 fields: set[str] = set() 

159 missing = object() 

160 for current, target in ( 

161 (state.parameters, self.parameters), 

162 (state._saved_parameters, self.saved_parameters), 

163 (state._live_resolved or {}, self.live_resolved or {}), 

164 (state._saved_resolved, self.saved_resolved), 

165 ): 

166 fields.update( 

167 key 

168 for key in set(current) | set(target) 

169 if not semantic_values_equal( 

170 current.get(key, missing), 

171 target.get(key, missing), 

172 ) 

173 ) 

174 return state._most_specific_notification_fields(fields) 

175 

176 @staticmethod 

177 def _checkpoint_mappings( 

178 state: ObjectState, 

179 mappings: dict[str, Any], 

180 *, 

181 preserve_value_identity: bool, 

182 ) -> dict[str, Any]: 

183 if preserve_value_identity: 

184 return mappings 

185 memo: dict[int, Any] = {} 

186 seen: set[int] = set() 

187 state._seed_callable_identity_memo(mappings, memo, seen) 

188 return copy.deepcopy(mappings, memo)