Coverage for src/prosemark/freewriting/adapters/file_system_adapter.py: 100%
95 statements
« prev ^ index » next coverage.py v7.8.0, created at 2025-09-24 18:08 +0000
« prev ^ index » next coverage.py v7.8.0, created at 2025-09-24 18:08 +0000
1"""File system adapter implementation for freewriting feature.
3This module provides concrete implementation of the FileSystemPort
4using standard Python file operations.
5"""
7from __future__ import annotations
9import shutil
10from pathlib import Path
12from prosemark.freewriting.domain.exceptions import FileSystemError
13from prosemark.freewriting.ports.file_system import FileSystemPort
16class FileSystemAdapter(FileSystemPort):
17 """Concrete implementation of FileSystemPort using standard file operations.
19 This adapter provides file system operations using Python's built-in
20 file handling capabilities and pathlib for path management.
21 """
23 def write_file(self, file_path: str, content: str, append: bool = False) -> None: # noqa: FBT001, FBT002
24 """Write content to file.
26 Args:
27 file_path: Target file path (should be absolute).
28 content: Content to write.
29 append: Whether to append (True) or overwrite (False).
31 Raises:
32 FileSystemError: If write operation fails.
34 """
35 try:
36 # Ensure parent directory exists
37 self.ensure_parent_directory(file_path)
39 mode = 'a' if append else 'w'
40 with Path(file_path).open(mode, encoding='utf-8') as f:
41 f.write(content)
43 except OSError as e: # pragma: no cover
44 raise FileSystemError('write', file_path, str(e)) from e
46 @classmethod
47 def read_file(cls, file_path: str) -> str:
48 """Read content from file.
50 Args:
51 file_path: Path to file to read.
53 Returns:
54 File content as string.
56 Raises:
57 FileSystemError: If read operation fails.
59 """
60 try:
61 with Path(file_path).open('r', encoding='utf-8') as f:
62 return f.read()
64 except OSError as e:
65 raise FileSystemError('read', file_path, str(e)) from e
67 @staticmethod
68 def file_exists(file_path: str) -> bool:
69 """Check if file exists.
71 Args:
72 file_path: Path to check.
74 Returns:
75 True if file exists, False otherwise.
77 """
78 return Path(file_path).exists()
80 @classmethod
81 def create_directory(cls, directory_path: str, parents: bool = True) -> None: # noqa: FBT001, FBT002
82 """Create directory if it doesn't exist.
84 Args:
85 directory_path: Path to directory to create.
86 parents: Whether to create parent directories if they don't exist.
88 Raises:
89 FileSystemError: If directory creation fails.
91 """
92 try:
93 Path(directory_path).mkdir(parents=parents, exist_ok=True)
95 except OSError as e:
96 raise FileSystemError('create_directory', directory_path, str(e)) from e
98 @staticmethod
99 def get_current_directory() -> str:
100 """Get current working directory.
102 Returns:
103 Absolute path to current directory.
105 """
106 return str(Path.cwd())
108 def is_writable(self, directory_path: str) -> bool:
109 """Check if directory is writable.
111 Args:
112 directory_path: Directory to check.
114 Returns:
115 True if writable, False otherwise.
117 """
118 try:
119 path = Path(directory_path)
121 # If directory doesn't exist, check if we can create it
122 if not path.exists():
123 try:
124 # Try to create it temporarily
125 path.mkdir(parents=True, exist_ok=True)
126 # If creation succeeded, remove it and check parent
127 if path.exists():
128 path.rmdir()
129 return self.is_writable(str(path.parent))
130 except OSError:
131 return False
132 else:
133 # If creation succeeded but directory doesn't exist (race condition)
134 return False # pragma: no cover
136 # Check if we can write by creating a temporary file
137 test_file = path / '.write_test'
138 try:
139 with test_file.open('w', encoding='utf-8') as f:
140 f.write('test')
141 test_file.unlink()
142 except OSError:
143 return False
144 else:
145 return True
147 except OSError:
148 return False
150 @staticmethod
151 def get_absolute_path(path: str) -> str:
152 """Convert path to absolute path.
154 Args:
155 path: Path to convert (can be relative or absolute).
157 Returns:
158 Absolute path string.
160 """
161 return str(Path(path).resolve())
163 @staticmethod
164 def join_paths(*paths: str) -> str:
165 """Join multiple path components into a single path.
167 Args:
168 *paths: Path components to join.
170 Returns:
171 Joined path string.
173 """
174 if not paths:
175 return ''
177 result = Path(paths[0])
178 for path in paths[1:]:
179 result /= path
180 return str(result)
182 @classmethod
183 def get_file_size(cls, file_path: str) -> int:
184 """Get size of file in bytes.
186 Args:
187 file_path: Path to file.
189 Returns:
190 File size in bytes.
192 Raises:
193 FileSystemError: If file doesn't exist or size cannot be determined.
195 """
196 try:
197 return Path(file_path).stat().st_size
199 except OSError as e:
200 raise FileSystemError('stat', file_path, str(e)) from e
202 @classmethod
203 def backup_file(cls, file_path: str, backup_suffix: str = '.bak') -> str:
204 """Create backup copy of file.
206 Args:
207 file_path: Path to file to backup.
208 backup_suffix: Suffix to add to backup file name.
210 Returns:
211 Path to backup file.
213 Raises:
214 FileSystemError: If backup creation fails.
216 """
217 try:
218 source_path = Path(file_path)
219 backup_path = source_path.with_suffix(source_path.suffix + backup_suffix)
221 # Copy the file to backup location
222 shutil.copy2(source_path, backup_path)
224 return str(backup_path)
226 except (OSError, shutil.Error) as e:
227 raise FileSystemError('backup', file_path, str(e)) from e
229 @classmethod
230 def ensure_parent_directory(cls, file_path: str) -> None:
231 """Ensure parent directory of file exists.
233 Args:
234 file_path: Path to file whose parent directory should exist.
236 Raises:
237 FileSystemError: If parent directory cannot be created.
239 """
240 try:
241 parent_dir = Path(file_path).parent
242 if not parent_dir.exists():
243 parent_dir.mkdir(parents=True, exist_ok=True)
245 except OSError as e:
246 raise FileSystemError('ensure_parent', str(Path(file_path).parent), str(e)) from e
248 @staticmethod
249 def sanitize_title(title: str) -> str:
250 """Sanitize a title string for use in filenames.
252 Args:
253 title: The title string to sanitize.
255 Returns:
256 Sanitized title safe for use in filenames.
258 """
259 # Replace potentially problematic characters with underscores
260 sanitized = title.replace('/', '_').replace('\\', '_').replace(':', '_').replace('*', '_')
261 sanitized = sanitized.replace('?', '_').replace('"', '_').replace('<', '_').replace('>', '_')
262 sanitized = sanitized.replace('|', '_')
264 # Remove leading/trailing whitespace and convert to clean format
265 sanitized = sanitized.strip()
267 # Collapse multiple underscores into single ones
268 while '__' in sanitized:
269 sanitized = sanitized.replace('__', '_')
271 # Remove leading/trailing underscores
272 return sanitized.strip('_')