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
« prev ^ index » next coverage.py v7.10.6, created at 2025-09-20 16:46 -0600
1"""File system resource provider."""
3import tempfile
4from contextlib import contextmanager
5from pathlib import Path
6from typing import Any, BinaryIO, TextIO, Union
8from dataknobs_fsm.functions.base import ResourceError
9from dataknobs_fsm.resources.base import (
10 BaseResourceProvider,
11 ResourceHealth,
12 ResourceStatus,
13)
16class FileHandle:
17 """Wrapper for file handles with metadata."""
19 def __init__(self, path: Path, handle: Union[TextIO, BinaryIO], mode: str):
20 """Initialize file handle.
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
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
38 def __enter__(self):
39 """Enter context manager."""
40 return self.handle
42 def __exit__(self, exc_type, exc_val, exc_tb):
43 """Exit context manager."""
44 self.close()
47class FileSystemResource(BaseResourceProvider):
48 """File system resource provider for file I/O operations."""
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.
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)
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()
74 # Set up temp directory
75 self.temp_dir = temp_dir
76 self._temp_files = []
77 self._open_handles = {}
79 self.status = ResourceStatus.IDLE
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.
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.
98 Returns:
99 FileHandle wrapper with the open file.
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_"
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 )
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 )
138 # Resolve path relative to base
139 file_path = self.base_path / path
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)
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)
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)
156 if self._resources:
157 self.status = ResourceStatus.ACTIVE
159 return file_handle
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
169 def release(self, resource: Any) -> None:
170 """Release a file handle.
172 Args:
173 resource: The FileHandle to release.
174 """
175 if isinstance(resource, FileHandle):
176 resource.close()
178 # Remove from tracking
179 handle_id = id(resource)
180 if handle_id in self._open_handles:
181 del self._open_handles[handle_id]
183 if resource in self._resources:
184 self._resources.remove(resource)
186 if not self._resources:
187 self.status = ResourceStatus.IDLE
189 def validate(self, resource: Any) -> bool:
190 """Validate a file handle.
192 Args:
193 resource: The FileHandle to validate.
195 Returns:
196 True if the handle is valid and not closed.
197 """
198 if not isinstance(resource, FileHandle):
199 return False
201 return not resource.closed and resource.handle and not resource.handle.closed # type: ignore
203 def health_check(self) -> ResourceHealth:
204 """Check file system health.
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()
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
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.
231 Args:
232 path: File path.
233 mode: File mode.
234 encoding: Text encoding.
235 **kwargs: Additional parameters.
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)
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.
256 Args:
257 suffix: File suffix.
258 prefix: File prefix.
259 mode: File mode.
260 encoding: Text encoding.
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)
276 def read_text(self, path: str, encoding: str = "utf-8") -> str:
277 """Read text from a file.
279 Args:
280 path: File path.
281 encoding: Text encoding.
283 Returns:
284 File contents as string.
285 """
286 with self.open(path, "r", encoding) as f:
287 return f.read()
289 def write_text(self, path: str, content: str, encoding: str = "utf-8") -> None:
290 """Write text to a file.
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)
300 def read_bytes(self, path: str) -> bytes:
301 """Read binary data from a file.
303 Args:
304 path: File path.
306 Returns:
307 File contents as bytes.
308 """
309 with self.open(path, "rb", encoding=None) as f:
310 return f.read()
312 def write_bytes(self, path: str, content: bytes) -> None:
313 """Write binary data to a file.
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)
322 def exists(self, path: str) -> bool:
323 """Check if a file exists.
325 Args:
326 path: File path.
328 Returns:
329 True if file exists.
330 """
331 file_path = self.base_path / path
332 return file_path.exists()
334 def delete(self, path: str) -> bool:
335 """Delete a file.
337 Args:
338 path: File path.
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
352 def list_files(self, pattern: str = "*") -> list[str]:
353 """List files matching a pattern.
355 Args:
356 pattern: Glob pattern.
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
367 def cleanup_temp_files(self) -> None:
368 """Clean up temporary files."""
369 import logging
370 logger = logging.getLogger(__name__)
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}")
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)
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)
400 # Clean up temp files
401 self.cleanup_temp_files()
403 # Call parent close
404 super().close()