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
« 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."""
3from dataclasses import dataclass, field
4from pathlib import Path
5from typing import Any
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
19@dataclass(frozen=True)
20class TemplateDirectory:
21 """Represents a collection of related templates organized as a directory.
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 """
27 name: str
28 path: DirectoryPath
29 templates: list[Template] = field(default_factory=list)
30 structure: dict[str, Any] = field(default_factory=dict)
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()
38 # Validate the directory
39 self._validate()
41 # Build structure representation
42 if not self.structure:
43 self._build_structure()
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))
50 templates = []
51 invalid_templates = []
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}')
63 if invalid_templates:
64 raise InvalidTemplateDirectoryError(str(self.path), invalid_templates)
66 if not templates: # pragma: no cover - defensive check, already validated by is_valid_template_directory
67 raise EmptyTemplateDirectoryError(str(self.path))
69 object.__setattr__(self, 'templates', templates)
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))
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))
82 # Check for placeholder consistency across templates
83 self._validate_placeholder_consistency()
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]] = {}
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)
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]
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))
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))
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 }
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
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'] = {}
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': {}}
146 current_level = current_subdirs[part]
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 }
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)
160 object.__setattr__(self, 'structure', structure)
162 @property
163 def directory_name(self) -> str:
164 """Get the directory name (alias for name property)."""
165 return self.name
167 @property
168 def directory_path(self) -> Path:
169 """Get the file system path to the directory."""
170 return self.path.value
172 @property
173 def template_count(self) -> int:
174 """Get the total number of templates in this directory."""
175 return len(self.templates)
177 @property
178 def all_placeholders(self) -> list[Placeholder]:
179 """Get all unique placeholders across all templates."""
180 unique_placeholders = {}
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
187 return list(unique_placeholders.values())
189 @property
190 def shared_placeholders(self) -> list[Placeholder]:
191 """Get placeholders that are used by multiple templates."""
192 placeholder_counts: dict[str, int] = {}
194 for template in self.templates:
195 for placeholder in template.placeholders:
196 placeholder_counts[placeholder.name] = placeholder_counts.get(placeholder.name, 0) + 1
198 shared: list[Placeholder] = []
199 unique_placeholders: dict[str, Placeholder] = {p.name: p for p in self.all_placeholders}
201 for name, count in placeholder_counts.items():
202 if count > 1:
203 shared.append(unique_placeholders[name])
205 return shared
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]
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]
217 @classmethod
218 def from_directory(cls, path: Path | str) -> 'TemplateDirectory':
219 """Create a TemplateDirectory by scanning a directory.
221 Args:
222 path: Path to the template directory
224 Returns:
225 New TemplateDirectory instance
227 Raises:
228 TemplateDirectoryNotFoundError: If directory does not exist
229 EmptyTemplateDirectoryError: If directory contains no templates
230 InvalidTemplateDirectoryError: If directory contains invalid templates
232 """
233 directory_path = DirectoryPath(path)
234 name = directory_path.name
236 return cls(name=name, path=directory_path)
238 def get_template_by_name(self, name: str) -> Template | None:
239 """Get a template by its name.
241 Args:
242 name: Template name to search for
244 Returns:
245 Template if found, None otherwise
247 """
248 for template in self.templates:
249 if template.name == name:
250 return template
251 return None
253 def get_templates_in_subdirectory(self, subdirectory: str) -> list[Template]:
254 """Get all templates in a specific subdirectory.
256 Args:
257 subdirectory: Name of subdirectory
259 Returns:
260 List of templates in the subdirectory
262 """
263 subdirectory_path = self.path.value / subdirectory
264 return [t for t in self.templates if subdirectory_path in t.file_path.parents]
266 def validate_placeholder_values(self, values: dict[str, str]) -> list[str]:
267 """Validate placeholder values against all templates.
269 Args:
270 values: Dictionary of placeholder names to values
272 Returns:
273 List of validation error messages (empty if valid)
275 """
276 errors = []
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))
288 return errors
290 def replace_placeholders_in_all(self, values: dict[str, str]) -> dict[str, str]:
291 """Replace placeholders in all templates with provided values.
293 Args:
294 values: Dictionary mapping placeholder names to replacement values
296 Returns:
297 Dictionary mapping template names to their content with placeholders replaced
299 Raises:
300 InvalidPlaceholderValueError: If placeholder validation fails
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))
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
318 return results
320 def to_dict(self) -> dict[str, Any]:
321 """Convert template directory to dictionary representation.
323 Returns:
324 Dictionary containing directory data
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 }