Coverage for src/prosemark/templates/domain/values/directory_path.py: 100%

98 statements  

« prev     ^ index     » next       coverage.py v7.8.0, created at 2025-09-30 23:09 +0000

1"""DirectoryPath value object for template directory handling and validation.""" 

2 

3from pathlib import Path 

4from typing import Self 

5 

6from prosemark.templates.domain.exceptions.template_exceptions import ( 

7 TemplateDirectoryNotFoundError, 

8 TemplateValidationError, 

9) 

10 

11 

12class DirectoryPath: 

13 """Immutable value object representing a validated template directory path. 

14 

15 This value object encapsulates a directory path and provides template-specific 

16 directory operations and validation. 

17 """ 

18 

19 def __init__(self, path: Path | str) -> None: 

20 """Initialize a directory path with validation. 

21 

22 Args: 

23 path: File system path to a directory 

24 

25 Raises: 

26 TemplateDirectoryNotFoundError: If the directory does not exist 

27 TemplateValidationError: If the path is not a valid directory 

28 

29 """ 

30 if isinstance(path, str): 

31 path = Path(path) 

32 

33 if not path.exists(): 

34 raise TemplateDirectoryNotFoundError(str(path)) 

35 

36 if not path.is_dir(): 

37 msg = f'Path must point to a directory, not a file: {path}' 

38 raise TemplateValidationError(msg, template_path=str(path)) 

39 

40 self._path = path.resolve() 

41 

42 @property 

43 def value(self) -> Path: 

44 """Get the absolute path to the directory.""" 

45 return self._path 

46 

47 @property 

48 def name(self) -> str: 

49 """Get the directory name.""" 

50 return self._path.name 

51 

52 @property 

53 def exists(self) -> bool: 

54 """Check if the directory still exists.""" 

55 return self._path.exists() and self._path.is_dir() 

56 

57 @property 

58 def template_count(self) -> int: 

59 """Count the number of .md files in this directory (non-recursive).""" 

60 if not self.exists: 

61 return 0 

62 

63 try: 

64 return len([f for f in self._path.iterdir() if f.is_file() and f.suffix == '.md']) 

65 except (OSError, PermissionError): # pragma: no cover 

66 return 0 

67 

68 @property 

69 def total_template_count(self) -> int: 

70 """Count all .md files in this directory recursively.""" 

71 if not self.exists: 

72 return 0 

73 

74 try: 

75 return len(list(self._path.rglob('*.md'))) 

76 except (OSError, PermissionError): # pragma: no cover 

77 return 0 

78 

79 @property 

80 def subdirectory_count(self) -> int: 

81 """Count the number of subdirectories in this directory.""" 

82 if not self.exists: 

83 return 0 

84 

85 try: 

86 return len([d for d in self._path.iterdir() if d.is_dir()]) 

87 except (OSError, PermissionError): # pragma: no cover 

88 return 0 

89 

90 @property 

91 def is_valid_template_directory(self) -> bool: 

92 """Check if this directory is a valid template directory. 

93 

94 A valid template directory either: 

95 1. Contains at least one .md file directly, OR 

96 2. Contains subdirectories that contain .md files 

97 """ 

98 if not self.exists: 

99 return False 

100 

101 # Check for direct .md files 

102 if self.template_count > 0: 

103 return True 

104 

105 # Check for .md files in subdirectories 

106 return self.total_template_count > self.template_count 

107 

108 def list_template_files(self, *, recursive: bool = False) -> list[Path]: 

109 """List all .md files in the directory. 

110 

111 Args: 

112 recursive: If True, search subdirectories recursively 

113 

114 Returns: 

115 List of paths to .md files 

116 

117 Raises: 

118 TemplateDirectoryNotFoundError: If directory no longer exists 

119 

120 """ 

121 if not self.exists: 

122 raise TemplateDirectoryNotFoundError(str(self._path)) 

123 

124 try: 

125 if recursive: 

126 return sorted(self._path.rglob('*.md')) 

127 return sorted([f for f in self._path.iterdir() if f.is_file() and f.suffix == '.md']) 

128 except (OSError, PermissionError) as e: # pragma: no cover 

129 msg = f'Cannot access directory: {self._path}' 

130 raise TemplateDirectoryNotFoundError(msg) from e 

131 

132 def list_subdirectories(self) -> list[Path]: 

133 """List all subdirectories. 

134 

135 Returns: 

136 List of paths to subdirectories 

137 

138 Raises: 

139 TemplateDirectoryNotFoundError: If directory no longer exists 

140 

141 """ 

142 if not self.exists: 

143 raise TemplateDirectoryNotFoundError(str(self._path)) 

144 

145 try: 

146 return sorted([d for d in self._path.iterdir() if d.is_dir()]) 

147 except (OSError, PermissionError) as e: # pragma: no cover 

148 msg = f'Cannot access directory: {self._path}' 

149 raise TemplateDirectoryNotFoundError(msg) from e 

150 

151 def find_template_file(self, template_name: str) -> Path | None: 

152 """Find a template file by name in this directory. 

153 

154 Args: 

155 template_name: Name of template (with or without .md extension) 

156 

157 Returns: 

158 Path to template file if found, None otherwise 

159 

160 """ 

161 if not self.exists: 

162 return None 

163 

164 # Ensure .md extension 

165 if not template_name.endswith('.md'): 

166 template_name = f'{template_name}.md' 

167 

168 template_file = self._path / template_name 

169 return template_file if template_file.is_file() else None 

170 

171 def find_subdirectory(self, directory_name: str) -> Path | None: 

172 """Find a subdirectory by name. 

173 

174 Args: 

175 directory_name: Name of subdirectory 

176 

177 Returns: 

178 Path to subdirectory if found, None otherwise 

179 

180 """ 

181 if not self.exists: 

182 return None 

183 

184 subdirectory = self._path / directory_name 

185 return subdirectory if subdirectory.is_dir() else None 

186 

187 def get_relative_path_to(self, target_path: Path) -> Path | None: 

188 """Get relative path from this directory to a target path. 

189 

190 Args: 

191 target_path: Target path to calculate relative path to 

192 

193 Returns: 

194 Relative path if possible, None if paths are not related 

195 

196 """ 

197 try: 

198 return target_path.relative_to(self._path) 

199 except ValueError: 

200 return None 

201 

202 def contains_path(self, path: Path) -> bool: 

203 """Check if a path is contained within this directory. 

204 

205 Args: 

206 path: Path to check 

207 

208 Returns: 

209 True if path is within this directory 

210 

211 """ 

212 return self.get_relative_path_to(path) is not None 

213 

214 @classmethod 

215 def create_if_not_exists(cls, path: Path | str) -> Self: 

216 """Create directory if it doesn't exist and return DirectoryPath. 

217 

218 Args: 

219 path: Path to directory to create 

220 

221 Returns: 

222 New DirectoryPath instance 

223 

224 Raises: 

225 TemplateValidationError: If path exists but is not a directory 

226 OSError: If directory cannot be created 

227 

228 """ 

229 if isinstance(path, str): 

230 path = Path(path) 

231 

232 if path.exists() and not path.is_dir(): 

233 msg = f'Path exists but is not a directory: {path}' 

234 raise TemplateValidationError(msg, template_path=str(path)) 

235 

236 if not path.exists(): 

237 path.mkdir(parents=True, exist_ok=True) 

238 

239 return cls(path) 

240 

241 def __str__(self) -> str: 

242 """String representation of the directory path.""" 

243 return str(self._path) 

244 

245 def __repr__(self) -> str: 

246 """Developer representation of the directory path.""" 

247 return f'DirectoryPath({self._path!r})' 

248 

249 def __eq__(self, other: object) -> bool: 

250 """Check equality with another DirectoryPath.""" 

251 if not isinstance(other, DirectoryPath): 

252 return NotImplemented 

253 return self._path == other._path 

254 

255 def __hash__(self) -> int: 

256 """Hash based on the path value.""" 

257 return hash(self._path)