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

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

2

3 This 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

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

10 from collections.abc import MutableMapping

11 import copy

12

13

14 class 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

190 class 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

233 def 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

267 def 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)