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

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

2

3 import tempfile

4 from contextlib import contextmanager

5 from pathlib import Path

6 from typing import Any, BinaryIO, TextIO, Union

7

8 from dataknobs_fsm.functions.base import ResourceError

9 from dataknobs_fsm.resources.base import (

10 BaseResourceProvider,

11 ResourceHealth,

12 ResourceStatus,

13)

14

15

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

47 class 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()