Coverage for src/prosemark/templates/domain/entities/template_directory.py: 100%

145 statements  

« prev     ^ index     » next       coverage.py v7.8.0, created at 2025-10-01 00:05 +0000

1"""TemplateDirectory entity representing a collection of related templates.""" 

2 

3from dataclasses import dataclass, field 

4from pathlib import Path 

5from typing import Any 

6 

7from prosemark.templates.domain.entities.placeholder import Placeholder 

8from prosemark.templates.domain.entities.template import Template 

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

10 EmptyTemplateDirectoryError, 

11 InvalidPlaceholderValueError, 

12 InvalidTemplateDirectoryError, 

13 TemplateParseError, 

14 TemplateValidationError, 

15) 

16from prosemark.templates.domain.values.directory_path import DirectoryPath 

17 

18 

19@dataclass(frozen=True) 

20class TemplateDirectory: 

21 """Represents a collection of related templates organized as a directory. 

22 

23 A template directory contains multiple template files that can be used together 

24 to create a structured set of related nodes with consistent placeholders. 

25 """ 

26 

27 name: str 

28 path: DirectoryPath 

29 templates: list[Template] = field(default_factory=list) 

30 structure: dict[str, Any] = field(default_factory=dict) 

31 

32 def __post_init__(self) -> None: 

33 """Validate template directory after initialization.""" 

34 # Load templates if not provided 

35 if not self.templates: 

36 self._load_templates() 

37 

38 # Validate the directory 

39 self._validate() 

40 

41 # Build structure representation 

42 if not self.structure: 

43 self._build_structure() 

44 

45 def _load_templates(self) -> None: 

46 """Load all template files from the directory.""" 

47 if not self.path.is_valid_template_directory: 

48 raise EmptyTemplateDirectoryError(str(self.path)) 

49 

50 templates = [] 

51 invalid_templates = [] 

52 

53 # Load templates recursively 

54 for template_file in self.path.list_template_files(recursive=True): 

55 try: 

56 template = Template.from_file(template_file) 

57 # Mark as directory template 

58 object.__setattr__(template, 'is_directory_template', True) 

59 templates.append(template) 

60 except (TemplateParseError, TemplateValidationError) as e: 

61 invalid_templates.append(f'{template_file.name}: {e}') 

62 

63 if invalid_templates: 

64 raise InvalidTemplateDirectoryError(str(self.path), invalid_templates) 

65 

66 if not templates: # pragma: no cover - defensive check, already validated by is_valid_template_directory 

67 raise EmptyTemplateDirectoryError(str(self.path)) 

68 

69 object.__setattr__(self, 'templates', templates) 

70 

71 def _validate(self) -> None: 

72 """Validate the template directory.""" 

73 if not self.templates: # pragma: no cover - defensive check, templates loaded by __post_init__ 

74 raise EmptyTemplateDirectoryError(str(self.path)) 

75 

76 # Validate that all templates are valid 

77 for template in self.templates: 

78 if not template.path.exists: 

79 msg = f'Template file no longer exists: {template.path}' 

80 raise TemplateValidationError(msg, template_path=str(template.path)) 

81 

82 # Check for placeholder consistency across templates 

83 self._validate_placeholder_consistency() 

84 

85 def _validate_placeholder_consistency(self) -> None: 

86 """Validate that shared placeholders are used consistently.""" 

87 # Collect all placeholders by name 

88 all_placeholders: dict[str, list[Placeholder]] = {} 

89 

90 for template in self.templates: 

91 for placeholder in template.placeholders: 

92 if placeholder.name not in all_placeholders: 

93 all_placeholders[placeholder.name] = [] 

94 all_placeholders[placeholder.name].append(placeholder) 

95 

96 # Check for consistency issues 

97 for placeholder_name, placeholder_list in all_placeholders.items(): 

98 if len(placeholder_list) > 1: 

99 # Multiple templates use this placeholder - check consistency 

100 first_placeholder = placeholder_list[0] 

101 

102 for placeholder in placeholder_list[1:]: 

103 # Check that required/optional status is consistent 

104 if placeholder.required != first_placeholder.required: 

105 msg = ( 

106 f"Placeholder '{placeholder_name}' has inconsistent required status " 

107 f'across templates in directory' 

108 ) 

109 raise TemplateValidationError(msg, template_path=str(self.path)) 

110 

111 # Check that default values are consistent 

112 if placeholder.default_value != first_placeholder.default_value: 

113 msg = ( 

114 f"Placeholder '{placeholder_name}' has inconsistent default values " 

115 f'across templates in directory' 

116 ) 

117 raise TemplateValidationError(msg, template_path=str(self.path)) 

118 

119 def _build_structure(self) -> None: 

120 """Build a representation of the directory structure.""" 

121 structure: dict[str, Any] = { 

122 'name': self.name, 

123 'path': str(self.path), 

124 'template_count': len(self.templates), 

125 'templates': [], 

126 'subdirectories': {}, 

127 } 

128 

129 # Group templates by their relative paths 

130 for template in self.templates: 

131 relative_path = self.path.get_relative_path_to(template.file_path) 

132 if relative_path: 

133 # Determine the directory structure 

134 parts = relative_path.parts 

135 current_level = structure 

136 

137 # Navigate to the correct subdirectory level 

138 for part in parts[:-1]: # All parts except the filename 

139 if 'subdirectories' not in current_level: # pragma: no cover - defensive check, always initialized 

140 current_level['subdirectories'] = {} 

141 

142 current_subdirs: dict[str, dict[str, Any]] = current_level['subdirectories'] 

143 if part not in current_subdirs: 

144 current_subdirs[part] = {'name': part, 'templates': [], 'subdirectories': {}} 

145 

146 current_level = current_subdirs[part] 

147 

148 # Add template to the appropriate level 

149 template_info = { 

150 'name': template.name, 

151 'path': str(relative_path), 

152 'placeholder_count': len(template.placeholders), 

153 'required_placeholders': [p.name for p in template.required_placeholders], 

154 } 

155 

156 if 'templates' not in current_level: # pragma: no cover - defensive check, always initialized 

157 current_level['templates'] = [] 

158 current_level['templates'].append(template_info) 

159 

160 object.__setattr__(self, 'structure', structure) 

161 

162 @property 

163 def directory_name(self) -> str: 

164 """Get the directory name (alias for name property).""" 

165 return self.name 

166 

167 @property 

168 def directory_path(self) -> Path: 

169 """Get the file system path to the directory.""" 

170 return self.path.value 

171 

172 @property 

173 def template_count(self) -> int: 

174 """Get the total number of templates in this directory.""" 

175 return len(self.templates) 

176 

177 @property 

178 def all_placeholders(self) -> list[Placeholder]: 

179 """Get all unique placeholders across all templates.""" 

180 unique_placeholders = {} 

181 

182 for template in self.templates: 

183 for placeholder in template.placeholders: 

184 if placeholder.name not in unique_placeholders: 

185 unique_placeholders[placeholder.name] = placeholder 

186 

187 return list(unique_placeholders.values()) 

188 

189 @property 

190 def shared_placeholders(self) -> list[Placeholder]: 

191 """Get placeholders that are used by multiple templates.""" 

192 placeholder_counts: dict[str, int] = {} 

193 

194 for template in self.templates: 

195 for placeholder in template.placeholders: 

196 placeholder_counts[placeholder.name] = placeholder_counts.get(placeholder.name, 0) + 1 

197 

198 shared: list[Placeholder] = [] 

199 unique_placeholders: dict[str, Placeholder] = {p.name: p for p in self.all_placeholders} 

200 

201 for name, count in placeholder_counts.items(): 

202 if count > 1: 

203 shared.append(unique_placeholders[name]) 

204 

205 return shared 

206 

207 @property 

208 def required_placeholders(self) -> list[Placeholder]: 

209 """Get all required placeholders across all templates.""" 

210 return [p for p in self.all_placeholders if p.required] 

211 

212 @property 

213 def optional_placeholders(self) -> list[Placeholder]: 

214 """Get all optional placeholders across all templates.""" 

215 return [p for p in self.all_placeholders if not p.required] 

216 

217 @classmethod 

218 def from_directory(cls, path: Path | str) -> 'TemplateDirectory': 

219 """Create a TemplateDirectory by scanning a directory. 

220 

221 Args: 

222 path: Path to the template directory 

223 

224 Returns: 

225 New TemplateDirectory instance 

226 

227 Raises: 

228 TemplateDirectoryNotFoundError: If directory does not exist 

229 EmptyTemplateDirectoryError: If directory contains no templates 

230 InvalidTemplateDirectoryError: If directory contains invalid templates 

231 

232 """ 

233 directory_path = DirectoryPath(path) 

234 name = directory_path.name 

235 

236 return cls(name=name, path=directory_path) 

237 

238 def get_template_by_name(self, name: str) -> Template | None: 

239 """Get a template by its name. 

240 

241 Args: 

242 name: Template name to search for 

243 

244 Returns: 

245 Template if found, None otherwise 

246 

247 """ 

248 for template in self.templates: 

249 if template.name == name: 

250 return template 

251 return None 

252 

253 def get_templates_in_subdirectory(self, subdirectory: str) -> list[Template]: 

254 """Get all templates in a specific subdirectory. 

255 

256 Args: 

257 subdirectory: Name of subdirectory 

258 

259 Returns: 

260 List of templates in the subdirectory 

261 

262 """ 

263 subdirectory_path = self.path.value / subdirectory 

264 return [t for t in self.templates if subdirectory_path in t.file_path.parents] 

265 

266 def validate_placeholder_values(self, values: dict[str, str]) -> list[str]: 

267 """Validate placeholder values against all templates. 

268 

269 Args: 

270 values: Dictionary of placeholder names to values 

271 

272 Returns: 

273 List of validation error messages (empty if valid) 

274 

275 """ 

276 errors = [] 

277 

278 # Check that all required placeholders have values 

279 for placeholder in self.required_placeholders: 

280 if placeholder.name not in values: 

281 errors.append(f'Missing value for required placeholder: {placeholder.name}') 

282 else: 

283 try: 

284 placeholder.validate_value(values[placeholder.name]) 

285 except (InvalidPlaceholderValueError, ValueError) as e: 

286 errors.append(str(e)) 

287 

288 return errors 

289 

290 def replace_placeholders_in_all(self, values: dict[str, str]) -> dict[str, str]: 

291 """Replace placeholders in all templates with provided values. 

292 

293 Args: 

294 values: Dictionary mapping placeholder names to replacement values 

295 

296 Returns: 

297 Dictionary mapping template names to their content with placeholders replaced 

298 

299 Raises: 

300 InvalidPlaceholderValueError: If placeholder validation fails 

301 

302 """ 

303 # Validate values first 

304 validation_errors = self.validate_placeholder_values(values) 

305 if validation_errors: 

306 msg = f'Placeholder validation failed: {"; ".join(validation_errors)}' 

307 raise TemplateValidationError(msg, template_path=str(self.path)) 

308 

309 # Replace placeholders in each template 

310 results = {} 

311 for template in self.templates: 

312 try: 

313 results[template.name] = template.replace_placeholders(values) 

314 except Exception as e: 

315 msg = f"Failed to replace placeholders in template '{template.name}': {e}" 

316 raise TemplateValidationError(msg, template_path=str(template.path)) from e 

317 

318 return results 

319 

320 def to_dict(self) -> dict[str, Any]: 

321 """Convert template directory to dictionary representation. 

322 

323 Returns: 

324 Dictionary containing directory data 

325 

326 """ 

327 return { 

328 'name': self.name, 

329 'path': str(self.path), 

330 'template_count': self.template_count, 

331 'templates': [t.to_dict() for t in self.templates], 

332 'structure': self.structure, 

333 'all_placeholders': [p.name for p in self.all_placeholders], 

334 'shared_placeholders': [p.name for p in self.shared_placeholders], 

335 'required_placeholders': [p.name for p in self.required_placeholders], 

336 'optional_placeholders': [p.name for p in self.optional_placeholders], 

337 }