Research-Stack/4-Infrastructure/NoDupeLabs/nodupe/tools/os_filesystem/filesystem.py

306 lines
9.1 KiB
Python

"""Filesystem Module.
Safe filesystem operations using standard library only.
Key Features:
- Safe file reading with error handling
- Safe file writing with atomic operations
- Path validation and sanitization
- File existence checking
- Directory operations
Dependencies:
- pathlib (standard library)
- os (standard library)
"""
from pathlib import Path
from typing import Optional, List
import os
import tempfile
import shutil
class FilesystemError(Exception):
"""Filesystem operation error"""
class Filesystem:
"""Handle safe filesystem operations.
All operations use standard library only and include comprehensive
error handling with graceful degradation.
"""
@staticmethod
def safe_read(file_path: Path, max_size: Optional[int] = None) -> bytes:
"""Safely read a file with size limits and error handling.
Args:
file_path: Path to file to read
max_size: Maximum file size in bytes (None = no limit)
Returns:
File contents as bytes
Raises:
FilesystemError: If file cannot be read or exceeds size limit
"""
try:
# Convert to Path object if string
if isinstance(file_path, str):
file_path = Path(file_path)
# Check if file exists
if not file_path.exists():
raise FilesystemError(f"File does not exist: {file_path}")
# Check if it's a file (not directory)
if not file_path.is_file():
raise FilesystemError(f"Path is not a file: {file_path}")
# Check file size if limit specified
if max_size is not None:
file_size = file_path.stat().st_size
if file_size > max_size:
raise FilesystemError(
f"File size {file_size} exceeds limit {max_size}: {file_path}"
)
# Read file
return file_path.read_bytes()
except OSError as e:
raise FilesystemError(f"Failed to read file {file_path}: {e}") from e
@staticmethod
def safe_write(file_path: Path, data: bytes, atomic: bool = True) -> None:
"""Safely write to a file with atomic operations.
Args:
file_path: Path to file to write
data: Data to write (bytes)
atomic: Use atomic write (write to temp then rename)
Raises:
FilesystemError: If file cannot be written
"""
try:
# Convert to Path object if string
if isinstance(file_path, str):
file_path = Path(file_path)
# Create parent directory if it doesn't exist
file_path.parent.mkdir(parents=True, exist_ok=True)
if atomic:
# Atomic write: write to temp file then rename
# This ensures file is never partially written
temp_fd, temp_path = tempfile.mkstemp(
dir=file_path.parent,
prefix=f".{file_path.name}.tmp"
)
try:
# Write to temp file
os.write(temp_fd, data)
os.close(temp_fd)
# Rename temp file to target (atomic on POSIX)
shutil.move(temp_path, file_path)
except Exception:
# Clean up temp file on error
try:
os.unlink(temp_path)
except OSError:
pass
raise
else:
# Direct write
file_path.write_bytes(data)
except OSError as e:
raise FilesystemError(f"Failed to write file {file_path}: {e}") from e
@staticmethod
def validate_path(file_path: Path, must_exist: bool = False) -> bool:
"""Validate file path.
Args:
file_path: Path to validate
must_exist: If True, path must exist
Returns:
True if path is valid
Raises:
FilesystemError: If path is invalid
"""
try:
# Convert to Path object if string
if isinstance(file_path, str):
file_path = Path(file_path)
# Resolve to absolute path
resolved = file_path.resolve()
# Check if must exist
if must_exist and not resolved.exists():
raise FilesystemError(f"Path does not exist: {file_path}")
return True
except (OSError, RuntimeError) as e:
raise FilesystemError(f"Invalid path {file_path}: {e}") from e
@staticmethod
def get_size(file_path: Path) -> int:
"""Get file size in bytes.
Args:
file_path: Path to file
Returns:
File size in bytes
Raises:
FilesystemError: If file size cannot be determined
"""
try:
if isinstance(file_path, str):
file_path = Path(file_path)
return file_path.stat().st_size
except OSError as e:
raise FilesystemError(f"Failed to get file size {file_path}: {e}") from e
@staticmethod
def list_directory(dir_path: Path, pattern: str = "*") -> List[Path]:
"""List files in directory matching pattern.
Args:
dir_path: Directory path
pattern: Glob pattern (default: "*")
Returns:
List of matching file paths
Raises:
FilesystemError: If directory cannot be listed
"""
try:
if isinstance(dir_path, str):
dir_path = Path(dir_path)
if not dir_path.is_dir():
raise FilesystemError(f"Path is not a directory: {dir_path}")
return list(dir_path.glob(pattern))
except OSError as e:
raise FilesystemError(f"Failed to list directory {dir_path}: {e}") from e
@staticmethod
def ensure_directory(dir_path: Path) -> None:
"""Ensure directory exists, create if necessary.
Args:
dir_path: Directory path
Raises:
FilesystemError: If directory cannot be created
"""
try:
if isinstance(dir_path, str):
dir_path = Path(dir_path)
dir_path.mkdir(parents=True, exist_ok=True)
except OSError as e:
raise FilesystemError(f"Failed to create directory {dir_path}: {e}") from e
@staticmethod
def remove_file(file_path: Path, missing_ok: bool = True) -> None:
"""Safely remove a file.
Args:
file_path: Path to file to remove
missing_ok: If True, don't raise error if file doesn't exist
Raises:
FilesystemError: If file cannot be removed
"""
try:
if isinstance(file_path, str):
file_path = Path(file_path)
file_path.unlink(missing_ok=missing_ok)
except OSError as e:
raise FilesystemError(f"Failed to remove file {file_path}: {e}") from e
@staticmethod
def copy_file(src: Path, dst: Path, overwrite: bool = False) -> None:
"""Safely copy a file.
Args:
src: Source file path
dst: Destination file path
overwrite: If True, overwrite existing destination file
Raises:
FilesystemError: If file cannot be copied
"""
try:
if isinstance(src, str):
src = Path(src)
if isinstance(dst, str):
dst = Path(dst)
if not src.exists():
raise FilesystemError(f"Source file does not exist: {src}")
if dst.exists() and not overwrite:
raise FilesystemError(f"Destination file exists: {dst}")
# Ensure destination directory exists
dst.parent.mkdir(parents=True, exist_ok=True)
# Copy file
shutil.copy2(src, dst)
except OSError as e:
raise FilesystemError(f"Failed to copy file {src} to {dst}: {e}") from e
@staticmethod
def move_file(src: Path, dst: Path, overwrite: bool = False) -> None:
"""Safely move a file.
Args:
src: Source file path
dst: Destination file path
overwrite: If True, overwrite existing destination file
Raises:
FilesystemError: If file cannot be moved
"""
try:
if isinstance(src, str):
src = Path(src)
if isinstance(dst, str):
dst = Path(dst)
if not src.exists():
raise FilesystemError(f"Source file does not exist: {src}")
if dst.exists() and not overwrite:
raise FilesystemError(f"Destination file exists: {dst}")
# Ensure destination directory exists
dst.parent.mkdir(parents=True, exist_ok=True)
# Move file
shutil.move(str(src), str(dst))
except OSError as e:
raise FilesystemError(f"Failed to move file {src} to {dst}: {e}") from e