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

1"""Template entity representing a prosemark template file.""" 

2 

3from dataclasses import dataclass, field 

4from pathlib import Path 

5from typing import Any 

6 

7import yaml 

8 

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 

17 

18 

19@dataclass(frozen=True) 

20class Template: 

21 """Represents a template file that can be used to create nodes. 

22 

23 A template contains YAML frontmatter and markdown content with placeholders 

24 that can be replaced with user-provided values during instantiation. 

25 """ 

26 

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 

34 

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

40 

41 # Validate the template 

42 self._validate() 

43 

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

48 

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 ) 

58 

59 frontmatter_text = parts[1].strip() 

60 body_text = parts[2].lstrip('\n') 

61 

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 = {} 

75 

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) 

79 

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) 

83 

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 

87 

88 def _extract_placeholders(self, text: str) -> list[Placeholder]: 

89 """Extract all placeholders from template text. 

90 

91 Args: 

92 text: The text to extract placeholders from 

93 

94 Returns: 

95 List of unique Placeholder objects 

96 

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 

106 

107 # Convert patterns to Placeholder objects 

108 placeholders = [] 

109 seen_names = set() 

110 

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 

117 

118 if default_key in self.frontmatter: 

119 default_value = str(self.frontmatter[default_key]) 

120 required = False 

121 

122 # Check for description in frontmatter 

123 desc_key = f'{pattern.name}_description' 

124 description = self.frontmatter.get(desc_key) 

125 

126 placeholder = Placeholder( 

127 name=pattern.name, 

128 pattern_obj=pattern, 

129 required=required, 

130 default_value=default_value, 

131 description=description, 

132 ) 

133 

134 placeholders.append(placeholder) 

135 seen_names.add(pattern.name) 

136 

137 return placeholders 

138 

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

144 

145 # Must have body content 

146 if not self.body.strip(): 

147 raise TemplateValidationError('Template must have body content', template_path=str(self.path)) 

148 

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 

154 

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 

158 

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

163 

164 @property 

165 def template_name(self) -> str: 

166 """Get the template name (alias for name property).""" 

167 return self.name 

168 

169 @property 

170 def file_path(self) -> Path: 

171 """Get the file system path to the template.""" 

172 return self.path.value 

173 

174 @property 

175 def has_placeholders(self) -> bool: 

176 """Check if this template has any placeholders.""" 

177 return len(self.placeholders) > 0 

178 

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] 

183 

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] 

188 

189 @classmethod 

190 def from_file(cls, path: Path | str) -> 'Template': 

191 """Create a Template by reading from a file. 

192 

193 Args: 

194 path: Path to the template file 

195 

196 Returns: 

197 New Template instance 

198 

199 Raises: 

200 TemplateNotFoundError: If file does not exist 

201 TemplateParseError: If file content is invalid 

202 TemplateValidationError: If template violates prosemark format 

203 

204 """ 

205 template_path = TemplatePath(path) 

206 content = template_path.read_content() 

207 name = template_path.name 

208 

209 return cls(name=name, path=template_path, content=content) 

210 

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. 

216 

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 

222 

223 Returns: 

224 New Template instance 

225 

226 Raises: 

227 TemplateParseError: If content is invalid 

228 TemplateValidationError: If template violates prosemark format 

229 

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 

236 

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 

240 

241 try: 

242 path = TemplatePath(temp_file_name) 

243 finally: 

244 Path(temp_file_name).unlink() 

245 

246 return cls(name=name, path=path, content=content, is_directory_template=is_directory_template) 

247 

248 def get_placeholder_by_name(self, name: str) -> Placeholder | None: 

249 """Get a placeholder by its name. 

250 

251 Args: 

252 name: Placeholder name to search for 

253 

254 Returns: 

255 Placeholder if found, None otherwise 

256 

257 """ 

258 for placeholder in self.placeholders: 

259 if placeholder.name == name: 

260 return placeholder 

261 return None 

262 

263 def replace_placeholders(self, values: dict[str, str]) -> str: 

264 """Replace placeholders in template content with provided values. 

265 

266 Args: 

267 values: Dictionary mapping placeholder names to replacement values 

268 

269 Returns: 

270 Template content with placeholders replaced 

271 

272 Raises: 

273 InvalidPlaceholderValueError: If required placeholders are missing 

274 

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

281 

282 # Start with original content 

283 result = self.content 

284 

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

288 

289 # Validate the value 

290 placeholder.validate_value(value) 

291 

292 # Replace in content 

293 result = placeholder.pattern_obj.replace_in_text(result, value) 

294 

295 return result 

296 

297 def render(self, values: dict[str, str]) -> str: 

298 """Render template with provided values, using defaults for missing optional placeholders. 

299 

300 Args: 

301 values: Dictionary mapping placeholder names to replacement values 

302 

303 Returns: 

304 Template content with placeholders replaced 

305 

306 Raises: 

307 TemplateValidationError: If required placeholders are missing 

308 

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

315 

316 # Start with original content 

317 result = self.content 

318 

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 

328 

329 # Replace in content using the pattern object 

330 result = placeholder.pattern_obj.replace_in_text(result, value) 

331 

332 return result 

333 

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

335 """Convert template to dictionary representation. 

336 

337 Returns: 

338 Dictionary containing template data 

339 

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 } 

351 

352 def __str__(self) -> str: 

353 """Return string representation of template.""" 

354 return f'Template(name={self.name}, path={self.path})'