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

1"""File system adapter implementation for freewriting feature. 

2 

3This module provides concrete implementation of the FileSystemPort 

4using standard Python file operations. 

5""" 

6 

7from __future__ import annotations 

8 

9import shutil 

10from pathlib import Path 

11 

12from prosemark.freewriting.domain.exceptions import FileSystemError 

13from prosemark.freewriting.ports.file_system import FileSystemPort 

14 

15 

16class FileSystemAdapter(FileSystemPort): 

17 """Concrete implementation of FileSystemPort using standard file operations. 

18 

19 This adapter provides file system operations using Python's built-in 

20 file handling capabilities and pathlib for path management. 

21 """ 

22 

23 def write_file(self, file_path: str, content: str, append: bool = False) -> None: # noqa: FBT001, FBT002 

24 """Write content to file. 

25 

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). 

30 

31 Raises: 

32 FileSystemError: If write operation fails. 

33 

34 """ 

35 try: 

36 # Ensure parent directory exists 

37 self.ensure_parent_directory(file_path) 

38 

39 mode = 'a' if append else 'w' 

40 with Path(file_path).open(mode, encoding='utf-8') as f: 

41 f.write(content) 

42 

43 except OSError as e: # pragma: no cover 

44 raise FileSystemError('write', file_path, str(e)) from e 

45 

46 @classmethod 

47 def read_file(cls, file_path: str) -> str: 

48 """Read content from file. 

49 

50 Args: 

51 file_path: Path to file to read. 

52 

53 Returns: 

54 File content as string. 

55 

56 Raises: 

57 FileSystemError: If read operation fails. 

58 

59 """ 

60 try: 

61 with Path(file_path).open('r', encoding='utf-8') as f: 

62 return f.read() 

63 

64 except OSError as e: 

65 raise FileSystemError('read', file_path, str(e)) from e 

66 

67 @staticmethod 

68 def file_exists(file_path: str) -> bool: 

69 """Check if file exists. 

70 

71 Args: 

72 file_path: Path to check. 

73 

74 Returns: 

75 True if file exists, False otherwise. 

76 

77 """ 

78 return Path(file_path).exists() 

79 

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. 

83 

84 Args: 

85 directory_path: Path to directory to create. 

86 parents: Whether to create parent directories if they don't exist. 

87 

88 Raises: 

89 FileSystemError: If directory creation fails. 

90 

91 """ 

92 try: 

93 Path(directory_path).mkdir(parents=parents, exist_ok=True) 

94 

95 except OSError as e: 

96 raise FileSystemError('create_directory', directory_path, str(e)) from e 

97 

98 @staticmethod 

99 def get_current_directory() -> str: 

100 """Get current working directory. 

101 

102 Returns: 

103 Absolute path to current directory. 

104 

105 """ 

106 return str(Path.cwd()) 

107 

108 def is_writable(self, directory_path: str) -> bool: 

109 """Check if directory is writable. 

110 

111 Args: 

112 directory_path: Directory to check. 

113 

114 Returns: 

115 True if writable, False otherwise. 

116 

117 """ 

118 try: 

119 path = Path(directory_path) 

120 

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 

135 

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 

146 

147 except OSError: 

148 return False 

149 

150 @staticmethod 

151 def get_absolute_path(path: str) -> str: 

152 """Convert path to absolute path. 

153 

154 Args: 

155 path: Path to convert (can be relative or absolute). 

156 

157 Returns: 

158 Absolute path string. 

159 

160 """ 

161 return str(Path(path).resolve()) 

162 

163 @staticmethod 

164 def join_paths(*paths: str) -> str: 

165 """Join multiple path components into a single path. 

166 

167 Args: 

168 *paths: Path components to join. 

169 

170 Returns: 

171 Joined path string. 

172 

173 """ 

174 if not paths: 

175 return '' 

176 

177 result = Path(paths[0]) 

178 for path in paths[1:]: 

179 result /= path 

180 return str(result) 

181 

182 @classmethod 

183 def get_file_size(cls, file_path: str) -> int: 

184 """Get size of file in bytes. 

185 

186 Args: 

187 file_path: Path to file. 

188 

189 Returns: 

190 File size in bytes. 

191 

192 Raises: 

193 FileSystemError: If file doesn't exist or size cannot be determined. 

194 

195 """ 

196 try: 

197 return Path(file_path).stat().st_size 

198 

199 except OSError as e: 

200 raise FileSystemError('stat', file_path, str(e)) from e 

201 

202 @classmethod 

203 def backup_file(cls, file_path: str, backup_suffix: str = '.bak') -> str: 

204 """Create backup copy of file. 

205 

206 Args: 

207 file_path: Path to file to backup. 

208 backup_suffix: Suffix to add to backup file name. 

209 

210 Returns: 

211 Path to backup file. 

212 

213 Raises: 

214 FileSystemError: If backup creation fails. 

215 

216 """ 

217 try: 

218 source_path = Path(file_path) 

219 backup_path = source_path.with_suffix(source_path.suffix + backup_suffix) 

220 

221 # Copy the file to backup location 

222 shutil.copy2(source_path, backup_path) 

223 

224 return str(backup_path) 

225 

226 except (OSError, shutil.Error) as e: 

227 raise FileSystemError('backup', file_path, str(e)) from e 

228 

229 @classmethod 

230 def ensure_parent_directory(cls, file_path: str) -> None: 

231 """Ensure parent directory of file exists. 

232 

233 Args: 

234 file_path: Path to file whose parent directory should exist. 

235 

236 Raises: 

237 FileSystemError: If parent directory cannot be created. 

238 

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) 

244 

245 except OSError as e: 

246 raise FileSystemError('ensure_parent', str(Path(file_path).parent), str(e)) from e 

247 

248 @staticmethod 

249 def sanitize_title(title: str) -> str: 

250 """Sanitize a title string for use in filenames. 

251 

252 Args: 

253 title: The title string to sanitize. 

254 

255 Returns: 

256 Sanitized title safe for use in filenames. 

257 

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('|', '_') 

263 

264 # Remove leading/trailing whitespace and convert to clean format 

265 sanitized = sanitized.strip() 

266 

267 # Collapse multiple underscores into single ones 

268 while '__' in sanitized: 

269 sanitized = sanitized.replace('__', '_') 

270 

271 # Remove leading/trailing underscores 

272 return sanitized.strip('_')