Coverage for src/dataknobs_fsm/resources/properties.py: 100%

113 statements  

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

1"""Properties custom resource for testing and demonstration. 

2 

3This module provides a simple dictionary-based resource that can be used 

4for testing FSM resource management and as an example of custom resources. 

5""" 

6 

7import logging 

8from datetime import datetime 

9from typing import Any, Dict, List 

10from dataknobs_fsm.resources.base import BaseResourceProvider, ResourceStatus, ResourceHealth 

11from dataknobs_fsm.functions.base import ResourceError 

12 

13logger = logging.getLogger(__name__) 

14 

15 

16class PropertiesHandle: 

17 """Handle for a properties resource instance.""" 

18 

19 def __init__(self, resource_name: str, owner_id: str, instance_id: str, properties: Dict[str, Any]): 

20 """Initialize properties handle. 

21 

22 Args: 

23 resource_name: Name of the resource. 

24 owner_id: ID of the owner. 

25 instance_id: Unique instance identifier. 

26 properties: Initial properties dictionary. 

27 """ 

28 self.resource_name = resource_name 

29 self.owner_id = owner_id 

30 self.instance_id = instance_id 

31 self.properties = properties.copy() # Create a copy to avoid external mutations 

32 self.created_at = datetime.now() 

33 self.accessed_at = datetime.now() 

34 self.access_count = 0 

35 self.modifications = [] 

36 

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

38 """Get a property value. 

39 

40 Args: 

41 key: Property key. 

42 default: Default value if key not found. 

43 

44 Returns: 

45 Property value or default. 

46 """ 

47 self.accessed_at = datetime.now() 

48 self.access_count += 1 

49 return self.properties.get(key, default) 

50 

51 def set(self, key: str, value: Any) -> None: 

52 """Set a property value. 

53 

54 Args: 

55 key: Property key. 

56 value: Property value. 

57 """ 

58 old_value = self.properties.get(key) 

59 self.properties[key] = value 

60 self.accessed_at = datetime.now() 

61 self.modifications.append({ 

62 'timestamp': datetime.now(), 

63 'key': key, 

64 'old_value': old_value, 

65 'new_value': value 

66 }) 

67 

68 def update(self, properties: Dict[str, Any]) -> None: 

69 """Update multiple properties. 

70 

71 Args: 

72 properties: Dictionary of properties to update. 

73 """ 

74 for key, value in properties.items(): 

75 self.set(key, value) 

76 

77 def delete(self, key: str) -> Any: 

78 """Delete a property. 

79 

80 Args: 

81 key: Property key to delete. 

82 

83 Returns: 

84 The deleted value or None. 

85 """ 

86 if key in self.properties: 

87 value = self.properties.pop(key) 

88 self.modifications.append({ 

89 'timestamp': datetime.now(), 

90 'key': key, 

91 'old_value': value, 

92 'new_value': None, 

93 'operation': 'delete' 

94 }) 

95 return value 

96 return None 

97 

98 def clear(self) -> None: 

99 """Clear all properties.""" 

100 self.properties.clear() 

101 self.modifications.append({ 

102 'timestamp': datetime.now(), 

103 'operation': 'clear' 

104 }) 

105 

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

107 """Convert handle to dictionary representation. 

108 

109 Returns: 

110 Dictionary with handle information. 

111 """ 

112 return { 

113 'resource_name': self.resource_name, 

114 'owner_id': self.owner_id, 

115 'instance_id': self.instance_id, 

116 'properties': self.properties.copy(), 

117 'created_at': self.created_at.isoformat(), 

118 'accessed_at': self.accessed_at.isoformat(), 

119 'access_count': self.access_count, 

120 'modification_count': len(self.modifications) 

121 } 

122 

123 

124class PropertiesResource(BaseResourceProvider): 

125 """Resource provider that manages property dictionaries. 

126 

127 This resource is useful for: 

128 - Testing resource management in FSMs 

129 - Storing configuration or state data 

130 - Demonstrating custom resource implementation 

131 - Tracking resource lifecycle and access patterns 

132 """ 

133 

134 def __init__( 

135 self, 

136 name: str, 

137 initial_properties: Dict[str, Any] | None = None, 

138 max_instances: int = 10, 

139 track_history: bool = True, 

140 **config 

141 ): 

142 """Initialize properties resource. 

143 

144 Args: 

145 name: Resource name. 

146 initial_properties: Initial properties for all instances. 

147 max_instances: Maximum number of concurrent instances. 

148 track_history: Whether to track acquisition/release history. 

149 **config: Additional configuration. 

150 """ 

151 super().__init__(name, config) 

152 

153 # Configuration 

154 self.initial_properties = initial_properties or {} 

155 self.max_instances = max_instances 

156 self.track_history = track_history 

157 

158 # Instance tracking 

159 self._instances: Dict[str, PropertiesHandle] = {} 

160 self._instance_counter = 0 

161 

162 # History tracking 

163 self._acquisition_history: List[Dict[str, Any]] = [] 

164 self._release_history: List[Dict[str, Any]] = [] 

165 

166 # State 

167 self.status = ResourceStatus.IDLE 

168 logger.info(f"Initialized PropertiesResource '{name}' with max_instances={max_instances}") 

169 

170 def acquire( 

171 self, 

172 owner_id: str | None = None, 

173 properties: Dict[str, Any] | None = None, 

174 **kwargs 

175 ) -> PropertiesHandle: 

176 """Acquire a properties resource instance. 

177 

178 Args: 

179 owner_id: ID of the owner (e.g., state name). 

180 properties: Additional properties to merge with initial properties. 

181 **kwargs: Additional parameters (ignored). 

182 

183 Returns: 

184 PropertiesHandle instance. 

185 

186 Raises: 

187 ResourceError: If maximum instances exceeded. 

188 """ 

189 # Check instance limit 

190 if len(self._instances) >= self.max_instances: 

191 raise ResourceError( 

192 f"Maximum instances ({self.max_instances}) exceeded", 

193 resource_name=self.name, 

194 operation="acquire" 

195 ) 

196 

197 # Generate instance ID 

198 self._instance_counter += 1 

199 instance_id = f"{self.name}_instance_{self._instance_counter}" 

200 owner_id = owner_id or "unknown" 

201 

202 # Merge properties 

203 merged_properties = self.initial_properties.copy() 

204 if properties: 

205 merged_properties.update(properties) 

206 

207 # Add metadata 

208 merged_properties['_metadata'] = { 

209 'resource_name': self.name, 

210 'instance_id': instance_id, 

211 'owner_id': owner_id, 

212 'acquired_at': datetime.now().isoformat() 

213 } 

214 

215 # Create handle 

216 handle = PropertiesHandle( 

217 resource_name=self.name, 

218 owner_id=owner_id, 

219 instance_id=instance_id, 

220 properties=merged_properties 

221 ) 

222 

223 # Track instance 

224 self._instances[instance_id] = handle 

225 self._resources.append(handle) 

226 

227 # Track history 

228 if self.track_history: 

229 self._acquisition_history.append({ 

230 'timestamp': datetime.now(), 

231 'instance_id': instance_id, 

232 'owner_id': owner_id, 

233 'properties': merged_properties.copy() 

234 }) 

235 

236 # Update status 

237 self.status = ResourceStatus.ACTIVE 

238 self.metrics.record_acquisition() 

239 

240 logger.debug(f"Acquired properties resource: {instance_id} for owner: {owner_id}") 

241 return handle 

242 

243 def release(self, resource: Any) -> None: 

244 """Release a properties resource instance. 

245 

246 Args: 

247 resource: PropertiesHandle or instance_id to release. 

248 """ 

249 # Handle different input types 

250 if isinstance(resource, PropertiesHandle): 

251 instance_id = resource.instance_id 

252 handle = resource 

253 elif isinstance(resource, str): 

254 instance_id = resource 

255 handle = self._instances.get(instance_id) 

256 else: 

257 logger.warning(f"Unknown resource type for release: {type(resource)}") 

258 return 

259 

260 # Remove from tracking 

261 if instance_id in self._instances: 

262 del self._instances[instance_id] 

263 

264 if handle and handle in self._resources: 

265 self._resources.remove(handle) 

266 

267 # Track history 

268 if self.track_history: 

269 self._release_history.append({ 

270 'timestamp': datetime.now(), 

271 'instance_id': instance_id, 

272 'owner_id': handle.owner_id, 

273 'final_properties': handle.properties.copy(), 

274 'access_count': handle.access_count, 

275 'modification_count': len(handle.modifications) 

276 }) 

277 

278 self.metrics.record_release( 

279 (datetime.now() - handle.created_at).total_seconds() 

280 ) 

281 

282 logger.debug(f"Released properties resource: {instance_id}") 

283 

284 # Update status 

285 if not self._instances: 

286 self.status = ResourceStatus.IDLE 

287 

288 def get_instance(self, instance_id: str) -> PropertiesHandle | None: 

289 """Get a specific instance by ID. 

290 

291 Args: 

292 instance_id: Instance identifier. 

293 

294 Returns: 

295 PropertiesHandle or None if not found. 

296 """ 

297 return self._instances.get(instance_id) 

298 

299 def get_all_instances(self) -> Dict[str, PropertiesHandle]: 

300 """Get all active instances. 

301 

302 Returns: 

303 Dictionary of instance_id -> PropertiesHandle. 

304 """ 

305 return self._instances.copy() 

306 

307 def health_check(self) -> ResourceHealth: 

308 """Check resource health. 

309 

310 Returns: 

311 Resource health status. 

312 """ 

313 if len(self._instances) >= self.max_instances: 

314 return ResourceHealth.UNHEALTHY 

315 elif len(self._instances) >= self.max_instances * 0.8: 

316 return ResourceHealth.DEGRADED 

317 else: 

318 return ResourceHealth.HEALTHY 

319 

320 def get_stats(self) -> Dict[str, Any]: 

321 """Get resource statistics. 

322 

323 Returns: 

324 Dictionary with resource statistics. 

325 """ 

326 stats = { 

327 'name': self.name, 

328 'status': self.status.value, 

329 'health': self.health_check().value, 

330 'active_instances': len(self._instances), 

331 'max_instances': self.max_instances, 

332 'total_acquisitions': len(self._acquisition_history), 

333 'total_releases': len(self._release_history), 

334 'instance_ids': list(self._instances.keys()) 

335 } 

336 

337 # Add instance details 

338 if self._instances: 

339 stats['instances'] = { 

340 instance_id: handle.to_dict() 

341 for instance_id, handle in self._instances.items() 

342 } 

343 

344 # Add metrics 

345 metrics = self.get_metrics() 

346 stats['metrics'] = { 

347 'total_acquisitions': metrics.total_acquisitions, 

348 'active_connections': metrics.active_connections, 

349 'failed_acquisitions': metrics.failed_acquisitions, 

350 'average_hold_time': metrics.average_hold_time, 

351 'average_acquisition_time': metrics.average_acquisition_time 

352 } 

353 

354 return stats 

355 

356 def reset(self) -> None: 

357 """Reset the resource, releasing all instances.""" 

358 # Release all instances 

359 for instance_id in list(self._instances.keys()): 

360 self.release(instance_id) 

361 

362 # Clear history if needed 

363 self._acquisition_history.clear() 

364 self._release_history.clear() 

365 self._instance_counter = 0 

366 

367 logger.info(f"Reset PropertiesResource '{self.name}'") 

368 

369 def __repr__(self) -> str: 

370 """String representation.""" 

371 return ( 

372 f"PropertiesResource(name='{self.name}', " 

373 f"instances={len(self._instances)}/{self.max_instances}, " 

374 f"status={self.status.value})" 

375 )