Coverage for src/prosemark/templates/domain/entities/template.py: 100%
147 statements
« prev ^ index » next coverage.py v7.8.0, created at 2025-09-30 23:09 +0000
« prev ^ index » next coverage.py v7.8.0, created at 2025-09-30 23:09 +0000
1"""Template entity representing a prosemark template file."""
3from dataclasses import dataclass, field
4from pathlib import Path
5from typing import Any
7import yaml
9from prosemark.templates.domain.entities.placeholder import Placeholder
10from prosemark.templates.domain.exceptions.template_exceptions import (
11 InvalidPlaceholderError,
12 TemplateParseError,
13 TemplateValidationError,
14)
15from prosemark.templates.domain.values.placeholder_pattern import PlaceholderPattern
16from prosemark.templates.domain.values.template_path import TemplatePath
19@dataclass(frozen=True)
20class Template:
21 """Represents a template file that can be used to create nodes.
23 A template contains YAML frontmatter and markdown content with placeholders
24 that can be replaced with user-provided values during instantiation.
25 """
27 name: str
28 path: TemplatePath
29 content: str
30 frontmatter: dict[str, Any] = field(default_factory=dict)
31 body: str = ''
32 placeholders: list[Placeholder] = field(default_factory=list)
33 is_directory_template: bool = False
35 def __post_init__(self) -> None:
36 """Parse and validate template content after initialization."""
37 # Parse content if frontmatter and body are not provided
38 if not self.frontmatter and not self.body and self.content:
39 self._parse_content()
41 # Validate the template
42 self._validate()
44 def _parse_content(self) -> None:
45 """Parse the template content into frontmatter and body."""
46 if not self.content.startswith('---'):
47 raise TemplateValidationError('Template must have YAML frontmatter', template_path=str(self.path))
49 try:
50 # Split content into frontmatter and body
51 # We expect exactly 3 parts: content before ---, frontmatter, content after ---
52 min_frontmatter_parts = 3
53 parts = self.content.split('---', 2)
54 if len(parts) < min_frontmatter_parts:
55 raise TemplateParseError(
56 'Template must have proper YAML frontmatter delimited by ---', template_path=str(self.path)
57 )
59 frontmatter_text = parts[1].strip()
60 body_text = parts[2].lstrip('\n')
62 # Parse YAML frontmatter
63 if frontmatter_text:
64 try:
65 parsed_frontmatter = yaml.safe_load(frontmatter_text)
66 if parsed_frontmatter is None:
67 parsed_frontmatter = {}
68 if not isinstance(parsed_frontmatter, dict):
69 raise TemplateParseError('YAML frontmatter must be a dictionary', template_path=str(self.path))
70 except yaml.YAMLError as e:
71 msg = f'Invalid YAML frontmatter: {e}'
72 raise TemplateParseError(msg, template_path=str(self.path)) from e
73 else:
74 parsed_frontmatter = {}
76 # Update frontmatter first so it's available during placeholder extraction
77 object.__setattr__(self, 'frontmatter', parsed_frontmatter)
78 object.__setattr__(self, 'body', body_text)
80 # Extract placeholders from both frontmatter and body (now that frontmatter is set)
81 placeholders = self._extract_placeholders(frontmatter_text + body_text)
82 object.__setattr__(self, 'placeholders', placeholders)
84 except (ValueError, AttributeError) as e: # pragma: no cover
85 msg = f'Error parsing template content: {e}'
86 raise TemplateParseError(msg, template_path=str(self.path)) from e
88 def _extract_placeholders(self, text: str) -> list[Placeholder]:
89 """Extract all placeholders from template text.
91 Args:
92 text: The text to extract placeholders from
94 Returns:
95 List of unique Placeholder objects
97 """
98 try:
99 patterns = PlaceholderPattern.extract_all_from_text(text)
100 except InvalidPlaceholderError:
101 # Let placeholder errors bubble up as-is
102 raise
103 except Exception as e: # pragma: no cover
104 msg = f'Error extracting placeholders: {e}'
105 raise TemplateParseError(msg, template_path=str(self.path)) from e
107 # Convert patterns to Placeholder objects
108 placeholders = []
109 seen_names = set()
111 for pattern in patterns:
112 if pattern.name not in seen_names:
113 # Check if this placeholder has a default value in frontmatter
114 default_key = f'{pattern.name}_default'
115 default_value = None
116 required = True
118 if default_key in self.frontmatter:
119 default_value = str(self.frontmatter[default_key])
120 required = False
122 # Check for description in frontmatter
123 desc_key = f'{pattern.name}_description'
124 description = self.frontmatter.get(desc_key)
126 placeholder = Placeholder(
127 name=pattern.name,
128 pattern_obj=pattern,
129 required=required,
130 default_value=default_value,
131 description=description,
132 )
134 placeholders.append(placeholder)
135 seen_names.add(pattern.name)
137 return placeholders
139 def _validate(self) -> None:
140 """Validate the template for prosemark compliance."""
141 # Must have frontmatter
142 if not self.frontmatter:
143 raise TemplateValidationError('Template must have YAML frontmatter', template_path=str(self.path))
145 # Must have body content
146 if not self.body.strip():
147 raise TemplateValidationError('Template must have body content', template_path=str(self.path))
149 # Body should start with a heading (prosemark convention)
150 body_lines = [line.strip() for line in self.body.strip().split('\n') if line.strip()]
151 if body_lines and not body_lines[0].startswith('#'):
152 # This is a warning rather than an error for flexibility
153 pass
155 # Validate that all placeholders in frontmatter have corresponding patterns in content
156 frontmatter_str = yaml.safe_dump(self.frontmatter)
157 all_patterns_text = frontmatter_str + self.body
159 for placeholder in self.placeholders:
160 if not placeholder.pattern_obj.matches_text(all_patterns_text):
161 msg = f"Placeholder '{placeholder.name}' defined but not used in template"
162 raise TemplateValidationError(msg, template_path=str(self.path))
164 @property
165 def template_name(self) -> str:
166 """Get the template name (alias for name property)."""
167 return self.name
169 @property
170 def file_path(self) -> Path:
171 """Get the file system path to the template."""
172 return self.path.value
174 @property
175 def has_placeholders(self) -> bool:
176 """Check if this template has any placeholders."""
177 return len(self.placeholders) > 0
179 @property
180 def required_placeholders(self) -> list[Placeholder]:
181 """Get list of placeholders that require user input."""
182 return [p for p in self.placeholders if p.required]
184 @property
185 def optional_placeholders(self) -> list[Placeholder]:
186 """Get list of placeholders with default values."""
187 return [p for p in self.placeholders if not p.required]
189 @classmethod
190 def from_file(cls, path: Path | str) -> 'Template':
191 """Create a Template by reading from a file.
193 Args:
194 path: Path to the template file
196 Returns:
197 New Template instance
199 Raises:
200 TemplateNotFoundError: If file does not exist
201 TemplateParseError: If file content is invalid
202 TemplateValidationError: If template violates prosemark format
204 """
205 template_path = TemplatePath(path)
206 content = template_path.read_content()
207 name = template_path.name
209 return cls(name=name, path=template_path, content=content)
211 @classmethod
212 def from_content(
213 cls, name: str, content: str, file_path: Path | str | None = None, *, is_directory_template: bool = False
214 ) -> 'Template':
215 """Create a Template from content string.
217 Args:
218 name: Template name
219 content: Raw template content
220 file_path: Optional path to source file
221 is_directory_template: Whether this is part of a directory template
223 Returns:
224 New Template instance
226 Raises:
227 TemplateParseError: If content is invalid
228 TemplateValidationError: If template violates prosemark format
230 """
231 if file_path:
232 path = TemplatePath(file_path)
233 else:
234 # Create a minimal path object for validation
235 from tempfile import NamedTemporaryFile
237 with NamedTemporaryFile(mode='w', suffix='.md', delete=False, encoding='utf-8') as temp_file:
238 temp_file.write(content)
239 temp_file_name = temp_file.name
241 try:
242 path = TemplatePath(temp_file_name)
243 finally:
244 Path(temp_file_name).unlink()
246 return cls(name=name, path=path, content=content, is_directory_template=is_directory_template)
248 def get_placeholder_by_name(self, name: str) -> Placeholder | None:
249 """Get a placeholder by its name.
251 Args:
252 name: Placeholder name to search for
254 Returns:
255 Placeholder if found, None otherwise
257 """
258 for placeholder in self.placeholders:
259 if placeholder.name == name:
260 return placeholder
261 return None
263 def replace_placeholders(self, values: dict[str, str]) -> str:
264 """Replace placeholders in template content with provided values.
266 Args:
267 values: Dictionary mapping placeholder names to replacement values
269 Returns:
270 Template content with placeholders replaced
272 Raises:
273 InvalidPlaceholderValueError: If required placeholders are missing
275 """
276 # Validate that all required placeholders have values
277 for placeholder in self.required_placeholders:
278 if placeholder.name not in values:
279 msg = f"Missing value for required placeholder '{placeholder.name}'"
280 raise TemplateParseError(msg, template_path=str(self.path))
282 # Start with original content
283 result = self.content
285 # Replace each placeholder
286 for placeholder in self.placeholders:
287 value = values[placeholder.name] if placeholder.name in values else placeholder.get_effective_value()
289 # Validate the value
290 placeholder.validate_value(value)
292 # Replace in content
293 result = placeholder.pattern_obj.replace_in_text(result, value)
295 return result
297 def render(self, values: dict[str, str]) -> str:
298 """Render template with provided values, using defaults for missing optional placeholders.
300 Args:
301 values: Dictionary mapping placeholder names to replacement values
303 Returns:
304 Template content with placeholders replaced
306 Raises:
307 TemplateValidationError: If required placeholders are missing
309 """
310 # Validate that all required placeholders have values
311 for placeholder in self.required_placeholders:
312 if placeholder.name not in values:
313 msg = f'Missing value for required placeholder: {placeholder.name}'
314 raise TemplateValidationError(msg, template_path=str(self.path))
316 # Start with original content
317 result = self.content
319 # Replace each placeholder
320 for placeholder in self.placeholders:
321 if placeholder.name in values:
322 value = values[placeholder.name]
323 elif placeholder.default_value is not None:
324 value = placeholder.default_value
325 else: # pragma: no cover
326 # This should not happen if validation above passed
327 continue
329 # Replace in content using the pattern object
330 result = placeholder.pattern_obj.replace_in_text(result, value)
332 return result
334 def to_dict(self) -> dict[str, Any]:
335 """Convert template to dictionary representation.
337 Returns:
338 Dictionary containing template data
340 """
341 return {
342 'name': self.name,
343 'path': str(self.path),
344 'has_placeholders': self.has_placeholders,
345 'placeholder_count': len(self.placeholders),
346 'required_placeholders': [p.name for p in self.required_placeholders],
347 'optional_placeholders': [p.name for p in self.optional_placeholders],
348 'is_directory_template': self.is_directory_template,
349 'frontmatter': self.frontmatter,
350 }
352 def __str__(self) -> str:
353 """Return string representation of template."""
354 return f'Template(name={self.name}, path={self.path})'