template.py 4.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130
  1. from pathlib import Path
  2. from typing import Any, Dict, List, Set, Tuple, Optional
  3. from dataclasses import dataclass, field
  4. import logging
  5. import re
  6. from jinja2 import Environment, BaseLoader, meta, nodes, TemplateSyntaxError
  7. import frontmatter
  8. logger = logging.getLogger(__name__)
  9. @dataclass
  10. class Template:
  11. """Data class for template information extracted from frontmatter."""
  12. # Required fields
  13. file_path: Path
  14. content: str = ""
  15. # Frontmatter fields with defaults
  16. id: str = ""
  17. name: str = ""
  18. description: str = "No description available"
  19. author: str = ""
  20. date: str = ""
  21. version: str = ""
  22. module: str = ""
  23. tags: List[str] = field(default_factory=list)
  24. files: List[str] = field(default_factory=list)
  25. # Template variable analysis results
  26. vars: Dict[str, Any] = field(default_factory=dict, init=False)
  27. @classmethod
  28. def from_file(cls, file_path: Path) -> "Template":
  29. """Create a Template instance from a file path.
  30. Args:
  31. file_path: Path to the template file
  32. """
  33. logger.debug(f"Loading template from file: {file_path}")
  34. try:
  35. frontmatter_data, content = cls._parse_frontmatter(file_path)
  36. template = cls(
  37. file_path=file_path,
  38. content=content,
  39. name=frontmatter_data.get('name', ''),
  40. description=frontmatter_data.get('description', 'No description available'),
  41. author=frontmatter_data.get('author', ''),
  42. date=frontmatter_data.get('date', ''),
  43. version=frontmatter_data.get('version', ''),
  44. module=frontmatter_data.get('module', ''),
  45. tags=frontmatter_data.get('tags', []),
  46. files=frontmatter_data.get('files', [])
  47. )
  48. # Store frontmatter variables - module enrichment will handle the integration
  49. template.frontmatter_variables = frontmatter_data.get('variables', {})
  50. if template.frontmatter_variables:
  51. logger.debug(f"Template '{template.id}' has {len(template.frontmatter_variables)} frontmatter variables: {list(template.frontmatter_variables.keys())}")
  52. logger.info(f"Loaded template '{template.id}' (v{template.version or 'unversioned'}")
  53. logger.debug(f"Template details: author='{template.author}', tags={template.tags}")
  54. return template
  55. except Exception as e:
  56. # If frontmatter parsing fails, create a basic Template object
  57. logger.warning(f"Failed to parse frontmatter for {file_path}: {e}. Creating basic template.")
  58. return cls(file_path=file_path)
  59. @staticmethod
  60. def _build_dotted_name(node) -> Optional[str]:
  61. """Build full dotted variable name from Jinja2 Getattr node.
  62. Returns:
  63. Dotted variable name (e.g., 'traefik.host') or None if invalid
  64. """
  65. current = node
  66. parts = []
  67. while isinstance(current, nodes.Getattr):
  68. parts.insert(0, current.attr)
  69. current = current.node
  70. if isinstance(current, nodes.Name):
  71. parts.insert(0, current.name)
  72. return '.'.join(parts)
  73. return None
  74. @staticmethod
  75. def _create_jinja_env() -> Environment:
  76. """Create standardized Jinja2 environment for consistent template processing."""
  77. return Environment(
  78. loader=BaseLoader(),
  79. trim_blocks=True, # Remove first newline after block tags
  80. lstrip_blocks=True, # Strip leading whitespace from block tags
  81. keep_trailing_newline=False # Remove trailing newlines
  82. )
  83. def _get_ast(self):
  84. """Get cached AST or create and cache it."""
  85. if self._jinja_ast is None:
  86. env = self._create_jinja_env()
  87. self._jinja_ast = env.parse(self.content)
  88. return self._jinja_ast
  89. def _get_used_variables(self) -> Set[str]:
  90. """Get variables actually used in template (cached)."""
  91. ast = self._get_ast()
  92. used_variables = meta.find_undeclared_variables(ast)
  93. initial_count = len(used_variables)
  94. # Handle dotted notation variables
  95. dotted_vars = []
  96. for node in ast.find_all(nodes.Getattr):
  97. dotted_name = Template._build_dotted_name(node)
  98. if dotted_name:
  99. used_variables.add(dotted_name)
  100. dotted_vars.append(dotted_name)
  101. if dotted_vars:
  102. logger.debug(f"Found {len(dotted_vars)} dotted variables in addition to {initial_count} simple variables")
  103. return used_variables
  104. @staticmethod
  105. def _parse_frontmatter(file_path: Path) -> Tuple[Dict[str, Any], str]:
  106. """Parse frontmatter and content from a file."""
  107. with open(file_path, 'r', encoding='utf-8') as f:
  108. post = frontmatter.load(f)
  109. return post.metadata, post.content