Coverage for src/prosemark/templates/adapters/prosemark_template_validator.py: 89%

196 statements  

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

1"""Prosemark-specific template validator adapter.""" 

2 

3import re 

4from typing import Any 

5 

6import yaml 

7 

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

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

10from prosemark.templates.domain.entities.template_directory import TemplateDirectory 

11from prosemark.templates.domain.exceptions.template_exceptions import InvalidPlaceholderValueError 

12from prosemark.templates.ports.template_validator_port import TemplateValidatorPort 

13 

14 

15class ProsemarkTemplateValidator(TemplateValidatorPort): 

16 """Prosemark-specific implementation of template validator.""" 

17 

18 def validate_template(self, template: Template) -> list[str]: 

19 """Validate a single template against prosemark standards. 

20 

21 Args: 

22 template: Template to validate 

23 

24 Returns: 

25 List of validation error messages (empty if valid) 

26 

27 """ 

28 errors: list[str] = [] 

29 

30 # Validate basic template structure 

31 errors.extend(self._validate_template_structure(template)) 

32 

33 # Validate prosemark-specific requirements 

34 errors.extend(self._validate_prosemark_format(template)) 

35 

36 # Validate placeholder consistency 

37 errors.extend(self._validate_placeholder_consistency(template)) 

38 

39 return errors 

40 

41 def validate_template_directory(self, template_directory: TemplateDirectory) -> list[str]: 

42 """Validate a template directory against prosemark standards. 

43 

44 Args: 

45 template_directory: Template directory to validate 

46 

47 Returns: 

48 List of validation error messages (empty if valid) 

49 

50 """ 

51 errors: list[str] = [] 

52 

53 # Validate each template in the directory 

54 for template in template_directory.templates: 

55 template_errors = self.validate_template(template) 

56 errors.extend(f"Template '{template.name}': {error}" for error in template_errors) 

57 

58 # Validate directory-specific requirements 

59 errors.extend(self._validate_directory_consistency(template_directory)) 

60 

61 return errors 

62 

63 @staticmethod 

64 def validate_placeholder_values(template: Template, values: dict[str, str]) -> list[str]: 

65 """Validate placeholder values for a template. 

66 

67 Args: 

68 template: Template to validate values against 

69 values: Dictionary of placeholder values to validate 

70 

71 Returns: 

72 List of validation error messages (empty if valid) 

73 

74 """ 

75 errors: list[str] = [] 

76 

77 # Check that all required placeholders have values 

78 for placeholder in template.required_placeholders: 

79 if placeholder.name not in values: 

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

81 else: 

82 # Validate the value 

83 try: 

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

85 except (InvalidPlaceholderValueError, ValueError) as e: 

86 errors.append(str(e)) 

87 

88 # Check for unexpected placeholder values 

89 template_placeholder_names = {p.name for p in template.placeholders} 

90 errors.extend(f'Unknown placeholder: {name}' for name in values if name not in template_placeholder_names) 

91 

92 return errors 

93 

94 def _validate_template_structure(self, template: Template) -> list[str]: 

95 """Validate basic template structure. 

96 

97 Args: 

98 template: Template to validate 

99 

100 Returns: 

101 List of validation errors 

102 

103 """ 

104 errors: list[str] = [] 

105 

106 # Must have frontmatter 

107 if not template.frontmatter: 107 ↛ 108line 107 didn't jump to line 108 because the condition on line 107 was never true

108 errors.append('Template must have YAML frontmatter') 

109 

110 # Must have body content 

111 if not template.body.strip(): 111 ↛ 112line 111 didn't jump to line 112 because the condition on line 111 was never true

112 errors.append('Template must have body content') 

113 

114 # Content must be valid markdown structure 

115 errors.extend(self._validate_content_structure(template)) 

116 

117 return errors 

118 

119 def _validate_prosemark_format(self, template: Template) -> list[str]: 

120 """Validate prosemark-specific format requirements. 

121 

122 Args: 

123 template: Template to validate 

124 

125 Returns: 

126 List of validation errors 

127 

128 """ 

129 errors: list[str] = [] 

130 

131 # Validate YAML frontmatter structure 

132 if template.frontmatter: 132 ↛ 136line 132 didn't jump to line 136 because the condition on line 132 was always true

133 errors.extend(self._validate_yaml_frontmatter(template.frontmatter)) 

134 

135 # Validate that body starts with a heading (prosemark convention) 

136 if template.body.strip(): 136 ↛ 143line 136 didn't jump to line 143 because the condition on line 136 was always true

137 lines = [line.strip() for line in template.body.strip().split('\n') if line.strip()] 

138 if lines and not lines[0].startswith('#'): 

139 # This is a warning for prosemark, not a hard error 

140 # Could be made configurable based on strictness level 

141 pass 

142 

143 return errors 

144 

145 def _validate_placeholder_consistency(self, template: Template) -> list[str]: 

146 """Validate placeholder usage consistency. 

147 

148 Args: 

149 template: Template to validate 

150 

151 Returns: 

152 List of validation errors 

153 

154 """ 

155 # Validate that all placeholders in frontmatter have corresponding patterns in content 

156 frontmatter_str = yaml.safe_dump(template.frontmatter) 

157 all_content = frontmatter_str + template.body 

158 

159 errors = [ 

160 f"Placeholder '{placeholder.name}' defined but not used in template" 

161 for placeholder in template.placeholders 

162 if not placeholder.pattern_obj.matches_text(all_content) 

163 ] 

164 

165 # Validate placeholder naming conventions 

166 errors.extend( 

167 f"Placeholder name '{placeholder.name}' violates naming conventions" 

168 for placeholder in template.placeholders 

169 if not self._is_valid_placeholder_name(placeholder.name) 

170 ) 

171 

172 return errors 

173 

174 @staticmethod 

175 def _validate_directory_consistency(template_directory: TemplateDirectory) -> list[str]: 

176 """Validate directory-specific consistency requirements. 

177 

178 Args: 

179 template_directory: Template directory to validate 

180 

181 Returns: 

182 List of validation errors 

183 

184 """ 

185 errors: list[str] = [] 

186 

187 # Check for shared placeholder consistency 

188 shared_placeholders = template_directory.shared_placeholders 

189 

190 for shared_placeholder in shared_placeholders: 

191 # Find all instances of this placeholder across templates 

192 placeholder_instances = [] 

193 for template in template_directory.templates: 

194 placeholder = template.get_placeholder_by_name(shared_placeholder.name) 

195 if placeholder: 195 ↛ 193line 195 didn't jump to line 193 because the condition on line 195 was always true

196 placeholder_instances.append((template.name, placeholder)) 

197 

198 # Validate consistency across instances 

199 if len(placeholder_instances) > 1: 199 ↛ 190line 199 didn't jump to line 190 because the condition on line 199 was always true

200 first_template_name, first_placeholder = placeholder_instances[0] 

201 

202 for template_name, placeholder in placeholder_instances[1:]: 

203 # Check required/optional consistency 

204 if placeholder.required != first_placeholder.required: 204 ↛ 205line 204 didn't jump to line 205 because the condition on line 204 was never true

205 errors.append( 

206 f"Placeholder '{placeholder.name}' has inconsistent required status " 

207 f"between templates '{first_template_name}' and '{template_name}'" 

208 ) 

209 

210 # Check default value consistency 

211 if placeholder.default_value != first_placeholder.default_value: 211 ↛ 212line 211 didn't jump to line 212 because the condition on line 211 was never true

212 errors.append( 

213 f"Placeholder '{placeholder.name}' has inconsistent default values " 

214 f"between templates '{first_template_name}' and '{template_name}'" 

215 ) 

216 

217 return errors 

218 

219 @staticmethod 

220 def _validate_content_structure(template: Template) -> list[str]: 

221 """Validate markdown content structure. 

222 

223 Args: 

224 template: Template to validate 

225 

226 Returns: 

227 List of validation errors 

228 

229 """ 

230 errors: list[str] = [] 

231 

232 # Check for malformed markdown structures 

233 content = template.body 

234 

235 # Basic markdown validation could be added here 

236 # For now, we'll keep it simple and just check for basic issues 

237 

238 # Check for unclosed code blocks 

239 if content.count('```') % 2 != 0: 

240 errors.append('Template contains unclosed code blocks') 

241 

242 # Check for malformed placeholder patterns 

243 import re 

244 

245 malformed_patterns = re.findall(r'\{[^{}]*\}(?!\})', content) 

246 malformed_patterns.extend(re.findall(r'(?<!\{)\{[^{}]*\}', content)) 

247 

248 # Filter out valid patterns 

249 valid_pattern = re.compile(r'\{\{[a-zA-Z_][a-zA-Z0-9_]*\}\}') 

250 truly_malformed = [pattern for pattern in malformed_patterns if not valid_pattern.match(pattern)] 

251 

252 if truly_malformed: 252 ↛ 253line 252 didn't jump to line 253 because the condition on line 252 was never true

253 errors.append(f'Template contains malformed placeholder patterns: {truly_malformed}') 

254 

255 return errors 

256 

257 def _validate_yaml_frontmatter(self, frontmatter: dict[str, Any]) -> list[str]: 

258 """Validate YAML frontmatter structure. 

259 

260 Args: 

261 frontmatter: Parsed frontmatter dictionary 

262 

263 Returns: 

264 List of validation errors 

265 

266 """ 

267 errors: list[str] = [] 

268 

269 # Validate that frontmatter is a dictionary 

270 if not isinstance(frontmatter, dict): 

271 errors.append('YAML frontmatter must be a dictionary') 

272 return errors 

273 

274 # Check for reserved keys that might conflict with prosemark 

275 reserved_keys = {'id', 'created', 'modified', 'type'} 

276 errors.extend(f'Frontmatter contains reserved key: {key}' for key in frontmatter if key in reserved_keys) 

277 

278 # Validate placeholder-related keys 

279 for key, value in frontmatter.items(): 

280 if key.endswith('_default'): 

281 placeholder_name = key[:-8] # Remove '_default' 

282 if not self._is_valid_placeholder_name(placeholder_name): 

283 errors.append(f'Invalid placeholder name in default key: {placeholder_name}') 

284 if not isinstance(value, str): 

285 errors.append(f'Default value for {placeholder_name} must be a string') 

286 

287 elif key.endswith('_description'): 

288 placeholder_name = key[:-12] # Remove '_description' 

289 if not self._is_valid_placeholder_name(placeholder_name): 

290 errors.append(f'Invalid placeholder name in description key: {placeholder_name}') 

291 if not isinstance(value, str): 

292 errors.append(f'Description for {placeholder_name} must be a string') 

293 

294 return errors 

295 

296 @staticmethod 

297 def _is_valid_placeholder_name(name: str) -> bool: 

298 """Check if a placeholder name follows naming conventions. 

299 

300 Args: 

301 name: Placeholder name to validate 

302 

303 Returns: 

304 True if name is valid, False otherwise 

305 

306 """ 

307 import re 

308 

309 # Must start with letter or underscore, followed by letters, digits, or underscores 

310 pattern = re.compile(r'^[a-zA-Z_][a-zA-Z0-9_]*$') 

311 return bool(pattern.match(name)) 

312 

313 @classmethod 

314 def validate_template_structure(cls, content: str) -> bool: 

315 """Validate that template content has valid structure. 

316 

317 Args: 

318 content: Raw template content 

319 

320 Returns: 

321 True if structure is valid 

322 

323 Raises: 

324 TemplateParseError: If YAML frontmatter is invalid 

325 TemplateValidationError: If content violates prosemark format 

326 

327 """ 

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

329 TemplateParseError, 

330 TemplateValidationError, 

331 ) 

332 

333 # Basic structure checks 

334 if not content.startswith('---'): 

335 raise TemplateValidationError('Template must have YAML frontmatter', template_path='<string>') 

336 

337 try: 

338 # Try to split and parse frontmatter 

339 parts = content.split('---', 2) 

340 min_frontmatter_parts = 3 

341 if len(parts) < min_frontmatter_parts: 341 ↛ 342line 341 didn't jump to line 342 because the condition on line 341 was never true

342 raise TemplateValidationError('Template must have YAML frontmatter', template_path='<string>') 

343 

344 frontmatter_text = parts[1].strip() 

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

346 

347 # Parse YAML frontmatter 

348 if frontmatter_text: 348 ↛ 360line 348 didn't jump to line 360 because the condition on line 348 was always true

349 try: 

350 parsed_frontmatter = yaml.safe_load(frontmatter_text) 

351 if parsed_frontmatter is None: 351 ↛ 352line 351 didn't jump to line 352 because the condition on line 351 was never true

352 parsed_frontmatter = {} 

353 if not isinstance(parsed_frontmatter, dict): 

354 raise TemplateParseError('YAML frontmatter must be a dictionary', template_path='<string>') 

355 except yaml.YAMLError as e: 

356 msg = f'Invalid YAML frontmatter: {e}' 

357 raise TemplateParseError(msg, template_path='<string>') from e 

358 

359 # Must have body content 

360 if not body_text.strip(): 

361 raise TemplateValidationError('Template must have body content', template_path='<string>') 

362 

363 return len(parts) >= min_frontmatter_parts 

364 

365 except (ValueError, AttributeError) as e: 

366 msg = f'Template structure validation failed: {e}' 

367 raise TemplateValidationError(msg, template_path='<string>') from e 

368 

369 @classmethod 

370 def validate_prosemark_format(cls, content: str) -> bool: 

371 """Validate that template follows prosemark node format. 

372 

373 Args: 

374 content: Raw template content 

375 

376 Returns: 

377 True if format is valid 

378 

379 Raises: 

380 TemplateValidationError: If content violates prosemark format requirements 

381 

382 """ 

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

384 TemplateValidationError, 

385 ) 

386 

387 try: 

388 # Basic format validation - should have frontmatter and body 

389 # Additional prosemark-specific checks could go here 

390 return cls.validate_template_structure(content) 

391 except (ValueError, yaml.YAMLError, AttributeError) as e: 

392 msg = f'Prosemark format validation failed: {e}' 

393 raise TemplateValidationError(msg, template_path='<string>') from e 

394 

395 @classmethod 

396 def extract_placeholders(cls, content: str) -> list[Placeholder]: 

397 """Extract all placeholders from template content. 

398 

399 Args: 

400 content: Template content containing placeholders 

401 

402 Returns: 

403 List of Placeholder instances found in content 

404 

405 Raises: 

406 InvalidPlaceholderError: If placeholder syntax is malformed 

407 

408 """ 

409 from prosemark.templates.domain.exceptions.template_exceptions import InvalidPlaceholderError 

410 

411 try: 

412 from prosemark.templates.domain.services.placeholder_service import PlaceholderService 

413 

414 service = PlaceholderService() 

415 

416 # Check for severely malformed patterns that indicate completely broken content 

417 severely_malformed_patterns = [ 

418 r'\{\{[^}]*\n', # Unclosed placeholders like {{unclosed 

419 ] 

420 

421 for pattern in severely_malformed_patterns: 

422 if re.search(pattern, content): 

423 raise InvalidPlaceholderError('Malformed placeholder pattern detected in content') 

424 

425 return service.extract_placeholders_from_text(content) 

426 except (ValueError, AttributeError) as e: 

427 msg = f'Failed to extract placeholders: {e}' 

428 raise InvalidPlaceholderError(msg) from e 

429 

430 @classmethod 

431 def validate_placeholder_syntax(cls, placeholder_text: str) -> bool: 

432 """Validate that a placeholder has correct syntax. 

433 

434 Args: 

435 placeholder_text: Placeholder pattern (e.g., "{{variable_name}}") 

436 

437 Returns: 

438 True if syntax is valid 

439 

440 Raises: 

441 InvalidPlaceholderError: If syntax is malformed 

442 

443 """ 

444 from prosemark.templates.domain.exceptions.template_exceptions import InvalidPlaceholderError 

445 

446 try: 

447 from prosemark.templates.domain.services.placeholder_service import PlaceholderService 

448 

449 service = PlaceholderService() 

450 result = service.validate_placeholder_pattern(placeholder_text) 

451 if result: 

452 return True 

453 msg = f'Invalid placeholder syntax: {placeholder_text}' 

454 raise InvalidPlaceholderError(msg) 

455 except (ValueError, yaml.YAMLError, AttributeError) as e: 

456 msg = f'Placeholder validation failed: {e}' 

457 raise InvalidPlaceholderError(msg) from e 

458 

459 @classmethod 

460 def validate_template_dependencies(cls, template: Template) -> bool: 

461 """Validate that template dependencies are resolvable. 

462 

463 Args: 

464 template: Template to validate dependencies for 

465 

466 Returns: 

467 True if all dependencies are valid 

468 

469 Raises: 

470 TemplateValidationError: If dependencies cannot be resolved 

471 

472 """ 

473 from prosemark.templates.domain.exceptions.template_exceptions import TemplateValidationError 

474 

475 # Basic dependency validation - check that placeholders are well-formed 

476 try: 

477 placeholders = template.placeholders 

478 

479 def validate_placeholders() -> bool: 

480 placeholder_list = list(placeholders) 

481 for placeholder in placeholder_list: 

482 cls.validate_placeholder_syntax(placeholder.pattern) 

483 return True 

484 

485 if not placeholders: 

486 return True 

487 return validate_placeholders() 

488 except (ValueError, yaml.YAMLError, AttributeError) as e: 

489 msg = f'Template dependency validation failed: {e}' 

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