Coverage for src/dataknobs_fsm/core/data_wrapper.py: 40%

117 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-09-20 16:46 -0600

1"""Data wrapper for FSM that provides a consistent interface for data access. 

2 

3This module implements a hybrid solution for data handling in the FSM: 

4- User functions receive raw dict data by default (simple, predictable) 

5- Optionally, users can work with FSMData wrapper for enhanced functionality 

6- Internal FSM operations use the wrapper for consistency 

7""" 

8 

9from typing import Any, Dict, Union, Iterator, KeysView, ValuesView, ItemsView 

10from collections.abc import MutableMapping 

11import copy 

12 

13 

14class FSMData(MutableMapping): 

15 """A data wrapper that supports both dict-style and attribute access. 

16 

17 This class provides: 

18 1. Dict-style access: data['key'] 

19 2. Attribute access: data.key 

20 3. Compatibility with existing functions expecting either pattern 

21 4. Transparent conversion to/from dict 

22 

23 The FSM internally uses this wrapper but always passes raw dict data 

24 to user functions unless they explicitly request the wrapper. 

25 """ 

26 

27 # Explicitly mark as unhashable (mutable mapping) 

28 __hash__ = None # type: ignore[assignment] 

29 

30 def __init__(self, data: Dict[str, Any] | None = None): 

31 """Initialize FSMData wrapper. 

32 

33 Args: 

34 data: Initial data dictionary. Defaults to empty dict. 

35 """ 

36 # Store data in __dict__ to avoid recursion with __getattr__ 

37 object.__setattr__(self, '_data', data if data is not None else {}) 

38 

39 # Dict-style access methods 

40 def __getitem__(self, key: str) -> Any: 

41 """Get item using dict-style access.""" 

42 return self._data[key] 

43 

44 def __setitem__(self, key: str, value: Any) -> None: 

45 """Set item using dict-style access.""" 

46 self._data[key] = value 

47 

48 def __delitem__(self, key: str) -> None: 

49 """Delete item using dict-style access.""" 

50 del self._data[key] 

51 

52 def __contains__(self, key: str) -> bool: 

53 """Check if key exists in data.""" 

54 return key in self._data 

55 

56 def __iter__(self) -> Iterator[str]: 

57 """Iterate over keys.""" 

58 return iter(self._data) 

59 

60 def __len__(self) -> int: 

61 """Get number of items.""" 

62 return len(self._data) 

63 

64 # Attribute-style access methods 

65 def __getattr__(self, name: str) -> Any: 

66 """Get attribute using dot notation.""" 

67 if name.startswith('_'): 

68 # Don't intercept private attributes 

69 raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'") 

70 try: 

71 return self._data[name] 

72 except KeyError: 

73 raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'") from None 

74 

75 def __setattr__(self, name: str, value: Any) -> None: 

76 """Set attribute using dot notation.""" 

77 if name.startswith('_'): 

78 # Store private attributes normally 

79 object.__setattr__(self, name, value) 

80 else: 

81 self._data[name] = value 

82 

83 def __delattr__(self, name: str) -> None: 

84 """Delete attribute using dot notation.""" 

85 if name.startswith('_'): 

86 object.__delattr__(self, name) 

87 else: 

88 try: 

89 del self._data[name] 

90 except KeyError: 

91 raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'") from None 

92 

93 # Dict-like methods 

94 def get(self, key: str, default: Any = None) -> Any: 

95 """Get value with default.""" 

96 return self._data.get(key, default) 

97 

98 def keys(self) -> KeysView[str]: 

99 """Get keys view.""" 

100 return self._data.keys() 

101 

102 def values(self) -> ValuesView[Any]: 

103 """Get values view.""" 

104 return self._data.values() 

105 

106 def items(self) -> ItemsView[str, Any]: 

107 """Get items view.""" 

108 return self._data.items() 

109 

110 def update(self, other: Union[Dict[str, Any], 'FSMData'] = None, **kwargs) -> None: 

111 """Update data from dict or another FSMData.""" 

112 if other is not None: 

113 if isinstance(other, FSMData): 

114 self._data.update(other._data) 

115 else: 

116 self._data.update(other) 

117 self._data.update(kwargs) 

118 

119 def clear(self) -> None: 

120 """Clear all data.""" 

121 self._data.clear() 

122 

123 def copy(self) -> 'FSMData': 

124 """Create a shallow copy.""" 

125 return FSMData(self._data.copy()) 

126 

127 def deepcopy(self) -> 'FSMData': 

128 """Create a deep copy.""" 

129 return FSMData(copy.deepcopy(self._data)) 

130 

131 def pop(self, key: str, default: Any = None) -> Any: 

132 """Remove and return value.""" 

133 return self._data.pop(key, default) 

134 

135 def setdefault(self, key: str, default: Any = None) -> Any: 

136 """Set default value if key doesn't exist.""" 

137 return self._data.setdefault(key, default) 

138 

139 # Conversion methods 

140 def to_dict(self) -> Dict[str, Any]: 

141 """Convert to plain dictionary. 

142 

143 Returns: 

144 The underlying data dictionary. 

145 """ 

146 return self._data 

147 

148 def __json__(self) -> Dict[str, Any]: 

149 """Support JSON serialization via json.dumps with default handler. 

150 

151 Returns: 

152 The underlying data dictionary for JSON serialization. 

153 """ 

154 return self._data 

155 

156 @classmethod 

157 def from_dict(cls, data: Dict[str, Any]) -> 'FSMData': 

158 """Create from dictionary. 

159 

160 Args: 

161 data: Dictionary to wrap. 

162 

163 Returns: 

164 New FSMData instance. 

165 """ 

166 return cls(data) 

167 

168 # Special methods for compatibility 

169 def __repr__(self) -> str: 

170 """String representation.""" 

171 return f"FSMData({self._data!r})" 

172 

173 def __str__(self) -> str: 

174 """String conversion.""" 

175 return str(self._data) 

176 

177 def __eq__(self, other: object) -> bool: 

178 """Equality comparison.""" 

179 if isinstance(other, FSMData): 

180 return self._data == other._data 

181 elif isinstance(other, dict): 

182 return self._data == other 

183 return False 

184 

185 def __bool__(self) -> bool: 

186 """Boolean conversion.""" 

187 return bool(self._data) 

188 

189 

190class StateDataWrapper: 

191 """Wrapper for state data that provides backward compatibility. 

192 

193 This wrapper is used for inline lambda functions that expect 

194 `state.data` access pattern. It wraps the FSMData to provide 

195 the expected interface. 

196 """ 

197 

198 data: Dict[str, Any] # Always stores the raw dict 

199 _fsm_data: FSMData # The FSMData wrapper 

200 

201 def __init__(self, data: Union[Dict[str, Any], FSMData, Any] = None): 

202 """Initialize state wrapper. 

203 

204 Args: 

205 data: Data to wrap (dict or FSMData). 

206 """ 

207 # Always expose the underlying dict for lambdas 

208 if isinstance(data, FSMData): 

209 self.data = data._data # Expose raw dict 

210 self._fsm_data = data 

211 elif isinstance(data, dict): 

212 self.data = data # Expose raw dict 

213 self._fsm_data = FSMData(data) 

214 else: 

215 # Convert to dict 

216 data_dict = dict(data) if data else {} 

217 self.data = data_dict 

218 self._fsm_data = FSMData(data_dict) 

219 

220 def __getattr__(self, name: str) -> Any: 

221 """Forward attribute access to data.""" 

222 return getattr(self.data, name) 

223 

224 def __getitem__(self, key: str) -> Any: 

225 """Forward dict-style access to data.""" 

226 return self.data[key] 

227 

228 def __setitem__(self, key: str, value: Any) -> None: 

229 """Forward dict-style setting to data.""" 

230 self.data[key] = value 

231 

232 

233def ensure_dict(data: Union[Dict[str, Any], FSMData, StateDataWrapper, Any]) -> Dict[str, Any]: 

234 """Ensure data is a plain dictionary. 

235 

236 This utility function converts various data types to a plain dict, 

237 which is what user functions expect to receive. 

238 

239 Args: 

240 data: Data in any supported format. 

241 

242 Returns: 

243 Plain dictionary. 

244 """ 

245 if isinstance(data, dict): 

246 return data 

247 elif isinstance(data, FSMData): 

248 return data.to_dict() 

249 elif isinstance(data, StateDataWrapper): 

250 return data.data.to_dict() 

251 elif hasattr(data, '_data'): 

252 # Handle other wrapper types 

253 return data._data 

254 elif hasattr(data, 'data'): 

255 # Handle objects with data attribute 

256 inner = data.data 

257 if isinstance(inner, dict): 

258 return inner 

259 elif isinstance(inner, FSMData): 

260 return inner.to_dict() 

261 elif hasattr(inner, '_data'): 

262 return inner._data 

263 # Last resort - try to convert 

264 return dict(data) if data else {} 

265 

266 

267def wrap_for_lambda(data: Union[Dict[str, Any], FSMData]) -> StateDataWrapper: 

268 """Wrap data for inline lambda functions. 

269 

270 This creates a wrapper that provides the `state.data` access pattern 

271 expected by inline lambda functions in the FSM configuration. 

272 

273 Args: 

274 data: Data to wrap. 

275 

276 Returns: 

277 StateDataWrapper instance. 

278 """ 

279 return StateDataWrapper(data)