Coverage for src/dataknobs_fsm/resources/filesystem.py: 0%

151 statements  

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

1"""File system resource provider.""" 

2 

3import tempfile 

4from contextlib import contextmanager 

5from pathlib import Path 

6from typing import Any, BinaryIO, TextIO, Union 

7 

8from dataknobs_fsm.functions.base import ResourceError 

9from dataknobs_fsm.resources.base import ( 

10 BaseResourceProvider, 

11 ResourceHealth, 

12 ResourceStatus, 

13) 

14 

15 

16class FileHandle: 

17 """Wrapper for file handles with metadata.""" 

18 

19 def __init__(self, path: Path, handle: Union[TextIO, BinaryIO], mode: str): 

20 """Initialize file handle. 

21  

22 Args: 

23 path: File path. 

24 handle: The file handle. 

25 mode: File open mode. 

26 """ 

27 self.path = path 

28 self.handle = handle 

29 self.mode = mode 

30 self.closed = False 

31 

32 def close(self) -> None: 

33 """Close the file handle.""" 

34 if not self.closed and self.handle: 

35 self.handle.close() 

36 self.closed = True 

37 

38 def __enter__(self): 

39 """Enter context manager.""" 

40 return self.handle 

41 

42 def __exit__(self, exc_type, exc_val, exc_tb): 

43 """Exit context manager.""" 

44 self.close() 

45 

46 

47class FileSystemResource(BaseResourceProvider): 

48 """File system resource provider for file I/O operations.""" 

49 

50 def __init__( 

51 self, 

52 name: str, 

53 base_path: str | None = None, 

54 temp_dir: str | None = None, 

55 **config 

56 ): 

57 """Initialize file system resource. 

58  

59 Args: 

60 name: Resource name. 

61 base_path: Base directory for file operations. 

62 temp_dir: Directory for temporary files. 

63 **config: Additional configuration. 

64 """ 

65 super().__init__(name, config) 

66 

67 # Set up base path 

68 if base_path: 

69 self.base_path = Path(base_path).resolve() 

70 self.base_path.mkdir(parents=True, exist_ok=True) 

71 else: 

72 self.base_path = Path.cwd() 

73 

74 # Set up temp directory 

75 self.temp_dir = temp_dir 

76 self._temp_files = [] 

77 self._open_handles = {} 

78 

79 self.status = ResourceStatus.IDLE 

80 

81 def acquire( 

82 self, 

83 path: str | None = None, 

84 mode: str = "r", 

85 encoding: str | None = "utf-8", 

86 temp: bool = False, 

87 **kwargs 

88 ) -> FileHandle: 

89 """Acquire a file handle. 

90  

91 Args: 

92 path: File path (relative to base_path). 

93 mode: File open mode. 

94 encoding: Text encoding (None for binary modes). 

95 temp: If True, create a temporary file. 

96 **kwargs: Additional open() parameters. 

97  

98 Returns: 

99 FileHandle wrapper with the open file. 

100  

101 Raises: 

102 ResourceError: If file operation fails. 

103 """ 

104 try: 

105 if temp: 

106 # Create temporary file 

107 suffix = Path(path).suffix if path else "" 

108 prefix = Path(path).stem if path else "tmp_" 

109 

110 if "b" in mode: 

111 handle = tempfile.NamedTemporaryFile( 

112 mode=mode, 

113 suffix=suffix, 

114 prefix=prefix, 

115 dir=self.temp_dir, 

116 delete=False 

117 ) 

118 else: 

119 handle = tempfile.NamedTemporaryFile( 

120 mode=mode, 

121 suffix=suffix, 

122 prefix=prefix, 

123 dir=self.temp_dir, 

124 delete=False, 

125 encoding=encoding 

126 ) 

127 

128 file_path = Path(handle.name) 

129 self._temp_files.append(file_path) 

130 else: 

131 if not path: 

132 raise ResourceError( 

133 "Path required for non-temporary files", 

134 resource_name=self.name, 

135 operation="acquire" 

136 ) 

137 

138 # Resolve path relative to base 

139 file_path = self.base_path / path 

140 

141 # Create parent directories if writing 

142 if any(m in mode for m in ["w", "a", "x"]): 

143 file_path.parent.mkdir(parents=True, exist_ok=True) 

144 

145 # Open file 

146 if "b" in mode: 

147 handle = open(file_path, mode, **kwargs) 

148 else: 

149 handle = open(file_path, mode, encoding=encoding, **kwargs) 

150 

151 # Create wrapper 

152 file_handle = FileHandle(file_path, handle, mode) # type: ignore 

153 self._open_handles[id(file_handle)] = file_handle 

154 self._resources.append(file_handle) 

155 

156 if self._resources: 

157 self.status = ResourceStatus.ACTIVE 

158 

159 return file_handle 

160 

161 except Exception as e: 

162 self.status = ResourceStatus.ERROR 

163 raise ResourceError( 

164 f"Failed to acquire file resource: {e}", 

165 resource_name=self.name, 

166 operation="acquire" 

167 ) from e 

168 

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

170 """Release a file handle. 

171  

172 Args: 

173 resource: The FileHandle to release. 

174 """ 

175 if isinstance(resource, FileHandle): 

176 resource.close() 

177 

178 # Remove from tracking 

179 handle_id = id(resource) 

180 if handle_id in self._open_handles: 

181 del self._open_handles[handle_id] 

182 

183 if resource in self._resources: 

184 self._resources.remove(resource) 

185 

186 if not self._resources: 

187 self.status = ResourceStatus.IDLE 

188 

189 def validate(self, resource: Any) -> bool: 

190 """Validate a file handle. 

191  

192 Args: 

193 resource: The FileHandle to validate. 

194  

195 Returns: 

196 True if the handle is valid and not closed. 

197 """ 

198 if not isinstance(resource, FileHandle): 

199 return False 

200 

201 return not resource.closed and resource.handle and not resource.handle.closed # type: ignore 

202 

203 def health_check(self) -> ResourceHealth: 

204 """Check file system health. 

205  

206 Returns: 

207 Health status. 

208 """ 

209 try: 

210 # Try to create and delete a temp file 

211 test_file = self.base_path / ".health_check" 

212 test_file.write_text("test") 

213 test_file.unlink() 

214 

215 self.metrics.record_health_check(True) 

216 return ResourceHealth.HEALTHY 

217 except Exception: 

218 self.metrics.record_health_check(False) 

219 return ResourceHealth.UNHEALTHY 

220 

221 @contextmanager 

222 def open( 

223 self, 

224 path: str, 

225 mode: str = "r", 

226 encoding: str | None = "utf-8", 

227 **kwargs 

228 ): 

229 """Context manager for file operations. 

230  

231 Args: 

232 path: File path. 

233 mode: File mode. 

234 encoding: Text encoding. 

235 **kwargs: Additional parameters. 

236  

237 Yields: 

238 The file handle. 

239 """ 

240 handle = self.acquire(path, mode, encoding, **kwargs) 

241 try: 

242 yield handle.handle 

243 finally: 

244 self.release(handle) 

245 

246 @contextmanager 

247 def temp_file( 

248 self, 

249 suffix: str = "", 

250 prefix: str = "tmp_", 

251 mode: str = "w", 

252 encoding: str | None = "utf-8" 

253 ): 

254 """Context manager for temporary files. 

255  

256 Args: 

257 suffix: File suffix. 

258 prefix: File prefix. 

259 mode: File mode. 

260 encoding: Text encoding. 

261  

262 Yields: 

263 The temporary file handle. 

264 """ 

265 handle = self.acquire( 

266 path=f"{prefix}file{suffix}", 

267 mode=mode, 

268 encoding=encoding, 

269 temp=True 

270 ) 

271 try: 

272 yield handle.handle 

273 finally: 

274 self.release(handle) 

275 

276 def read_text(self, path: str, encoding: str = "utf-8") -> str: 

277 """Read text from a file. 

278  

279 Args: 

280 path: File path. 

281 encoding: Text encoding. 

282  

283 Returns: 

284 File contents as string. 

285 """ 

286 with self.open(path, "r", encoding) as f: 

287 return f.read() 

288 

289 def write_text(self, path: str, content: str, encoding: str = "utf-8") -> None: 

290 """Write text to a file. 

291  

292 Args: 

293 path: File path. 

294 content: Content to write. 

295 encoding: Text encoding. 

296 """ 

297 with self.open(path, "w", encoding) as f: 

298 f.write(content) 

299 

300 def read_bytes(self, path: str) -> bytes: 

301 """Read binary data from a file. 

302  

303 Args: 

304 path: File path. 

305  

306 Returns: 

307 File contents as bytes. 

308 """ 

309 with self.open(path, "rb", encoding=None) as f: 

310 return f.read() 

311 

312 def write_bytes(self, path: str, content: bytes) -> None: 

313 """Write binary data to a file. 

314  

315 Args: 

316 path: File path. 

317 content: Binary content to write. 

318 """ 

319 with self.open(path, "wb", encoding=None) as f: 

320 f.write(content) 

321 

322 def exists(self, path: str) -> bool: 

323 """Check if a file exists. 

324  

325 Args: 

326 path: File path. 

327  

328 Returns: 

329 True if file exists. 

330 """ 

331 file_path = self.base_path / path 

332 return file_path.exists() 

333 

334 def delete(self, path: str) -> bool: 

335 """Delete a file. 

336  

337 Args: 

338 path: File path. 

339  

340 Returns: 

341 True if file was deleted. 

342 """ 

343 try: 

344 file_path = self.base_path / path 

345 if file_path.exists(): 

346 file_path.unlink() 

347 return True 

348 return False 

349 except Exception: 

350 return False 

351 

352 def list_files(self, pattern: str = "*") -> list[str]: 

353 """List files matching a pattern. 

354  

355 Args: 

356 pattern: Glob pattern. 

357  

358 Returns: 

359 List of file paths. 

360 """ 

361 files = [] 

362 for path in self.base_path.glob(pattern): 

363 if path.is_file(): 

364 files.append(str(path.relative_to(self.base_path))) 

365 return files 

366 

367 def cleanup_temp_files(self) -> None: 

368 """Clean up temporary files.""" 

369 import logging 

370 logger = logging.getLogger(__name__) 

371 

372 cleanup_errors = [] 

373 for temp_file in self._temp_files[:]: 

374 try: 

375 if temp_file.exists(): 

376 temp_file.unlink() 

377 self._temp_files.remove(temp_file) 

378 except PermissionError as e: 

379 cleanup_errors.append(f"Permission denied cleaning up {temp_file}: {e}") 

380 logger.warning(f"Could not delete temporary file {temp_file}: {e}") 

381 except OSError as e: 

382 cleanup_errors.append(f"OS error cleaning up {temp_file}: {e}") 

383 logger.warning(f"OS error deleting temporary file {temp_file}: {e}") 

384 except Exception as e: 

385 cleanup_errors.append(f"Unexpected error cleaning up {temp_file}: {e}") 

386 logger.error(f"Unexpected error cleaning up {temp_file}: {e}") 

387 

388 if cleanup_errors: 

389 # Store cleanup errors in metadata for debugging 

390 if not hasattr(self, '_cleanup_errors'): 

391 self._cleanup_errors = [] 

392 self._cleanup_errors.extend(cleanup_errors) 

393 

394 def close(self) -> None: 

395 """Close all open handles and clean up.""" 

396 # Close all open handles 

397 for handle in list(self._open_handles.values()): 

398 self.release(handle) 

399 

400 # Clean up temp files 

401 self.cleanup_temp_files() 

402 

403 # Call parent close 

404 super().close()