Coverage for src / moai_adk / core / template_engine.py: 20.55%
73 statements
« prev ^ index » next coverage.py v7.12.0, created at 2025-11-20 20:52 +0900
« prev ^ index » next coverage.py v7.12.0, created at 2025-11-20 20:52 +0900
1"""
2Template engine for parameterizing GitHub templates and other configuration files.
4Supports Jinja2-style templating with variable substitution and conditional sections.
5Enables users to customize MoAI-ADK templates for their own projects.
7"""
9from pathlib import Path
10from typing import Any, Dict, Optional
12from jinja2 import (
13 Environment,
14 FileSystemLoader,
15 StrictUndefined,
16 TemplateNotFound,
17 TemplateRuntimeError,
18 TemplateSyntaxError,
19 Undefined,
20)
23class TemplateEngine:
24 """
25 Jinja2-based template engine for MoAI-ADK configuration and GitHub templates.
27 Supports:
28 - Variable substitution: {{PROJECT_NAME}}, {{SPEC_DIR}}, etc.
29 - Conditional sections: {{#ENABLE_TRUST_5}}...{{/ENABLE_TRUST_5}}
30 - File-based and string-based template rendering
31 """
33 def __init__(self, strict_undefined: bool = True):
34 """
35 Initialize the template engine.
37 Args:
38 strict_undefined: If True, raise error on undefined variables (default: True).
39 If False, render undefined variables as empty strings.
41 Note:
42 Changed to strict_undefined=True (v0.10.2+) for safer template rendering.
43 Variables must be explicitly provided to avoid silent template failures.
44 """
45 self.strict_undefined = strict_undefined
46 self.undefined_behavior = StrictUndefined if strict_undefined else Undefined
48 def render_string(self, template_string: str, variables: Dict[str, Any]) -> str:
49 """
50 Render a Jinja2 template string with provided variables.
52 Args:
53 template_string: The template content as a string
54 variables: Dictionary of variables to substitute
56 Returns:
57 Rendered template string
59 Raises:
60 TemplateSyntaxError: If template syntax is invalid
61 TemplateRuntimeError: If variable substitution fails in strict mode
62 """
63 try:
64 env = Environment(
65 undefined=self.undefined_behavior,
66 trim_blocks=False,
67 lstrip_blocks=False,
68 )
69 template = env.from_string(template_string)
70 return template.render(**variables)
71 except (TemplateSyntaxError, TemplateRuntimeError) as e:
72 raise RuntimeError(f"Template rendering error: {e}")
74 def render_file(
75 self,
76 template_path: Path,
77 variables: Dict[str, Any],
78 output_path: Optional[Path] = None,
79 ) -> str:
80 """
81 Render a Jinja2 template file with provided variables.
83 Args:
84 template_path: Path to the template file
85 variables: Dictionary of variables to substitute
86 output_path: If provided, write rendered content to this path
88 Returns:
89 Rendered template content
91 Raises:
92 FileNotFoundError: If template file doesn't exist
93 TemplateSyntaxError: If template syntax is invalid
94 TemplateRuntimeError: If variable substitution fails in strict mode
95 """
96 if not template_path.exists():
97 raise FileNotFoundError(f"Template file not found: {template_path}")
99 template_dir = template_path.parent
100 template_name = template_path.name
102 try:
103 env = Environment(
104 loader=FileSystemLoader(str(template_dir)),
105 undefined=self.undefined_behavior,
106 trim_blocks=False,
107 lstrip_blocks=False,
108 )
109 template = env.get_template(template_name)
110 rendered = template.render(**variables)
112 if output_path:
113 output_path.parent.mkdir(parents=True, exist_ok=True)
114 output_path.write_text(rendered, encoding="utf-8")
116 return rendered
117 except TemplateNotFound:
118 raise FileNotFoundError(
119 f"Template not found in {template_dir}: {template_name}"
120 )
121 except (TemplateSyntaxError, TemplateRuntimeError) as e:
122 raise RuntimeError(f"Template rendering error in {template_path}: {e}")
124 def render_directory(
125 self,
126 template_dir: Path,
127 output_dir: Path,
128 variables: Dict[str, Any],
129 pattern: str = "**/*",
130 ) -> Dict[str, str]:
131 """
132 Render all template files in a directory.
134 Args:
135 template_dir: Source directory containing templates
136 output_dir: Destination directory for rendered files
137 variables: Dictionary of variables to substitute
138 pattern: Glob pattern for files to process (default: all files)
140 Returns:
141 Dictionary mapping input paths to rendered content
143 Raises:
144 FileNotFoundError: If template directory doesn't exist
145 """
146 if not template_dir.exists():
147 raise FileNotFoundError(f"Template directory not found: {template_dir}")
149 results = {}
150 output_dir.mkdir(parents=True, exist_ok=True)
152 for template_file in template_dir.glob(pattern):
153 if template_file.is_file():
154 relative_path = template_file.relative_to(template_dir)
155 output_file = output_dir / relative_path
157 try:
158 rendered = self.render_file(template_file, variables, output_file)
159 results[str(relative_path)] = rendered
160 except Exception as e:
161 raise RuntimeError(f"Error rendering {relative_path}: {e}")
163 return results
165 @staticmethod
166 def get_default_variables(config: Dict[str, Any]) -> Dict[str, Any]:
167 """
168 Extract template variables from project configuration.
170 Args:
171 config: Project configuration dictionary (from .moai/config/config.json)
173 Returns:
174 Dictionary of template variables
175 """
176 github_config = config.get("github", {}).get("templates", {})
177 project_config = config.get("project", {})
178 user_config = config.get("user", {})
180 return {
181 # Project information
182 "PROJECT_NAME": project_config.get("name", "MyProject"),
183 "PROJECT_DESCRIPTION": project_config.get("description", ""),
184 "PROJECT_OWNER": project_config.get("owner", ""),
185 "PROJECT_MODE": project_config.get("mode", "team"), # team or personal
186 "CODEBASE_LANGUAGE": project_config.get("codebase_language", "python"),
187 # User information
188 "USER_NAME": user_config.get("name", ""),
189 # Directory structure
190 "SPEC_DIR": github_config.get("spec_directory", ".moai/specs"),
191 "DOCS_DIR": github_config.get("docs_directory", ".moai/docs"),
192 "TEST_DIR": github_config.get("test_directory", "tests"),
193 # Feature flags
194 "ENABLE_TRUST_5": github_config.get("enable_trust_5", True),
195 "ENABLE_ALFRED_COMMANDS": github_config.get("enable_alfred_commands", True),
196 # Language configuration
197 "CONVERSATION_LANGUAGE": config.get("language", {}).get(
198 "conversation_language", "en"
199 ),
200 "CONVERSATION_LANGUAGE_NAME": config.get("language", {}).get(
201 "conversation_language_name", "English"
202 ),
203 "AGENT_PROMPT_LANGUAGE": config.get("language", {}).get(
204 "agent_prompt_language", "english"
205 ),
206 # Additional metadata
207 "MOAI_VERSION": config.get("moai", {}).get("version", "0.7.0"),
208 }
211class TemplateVariableValidator:
212 """
213 Validates template variables for completeness and correctness.
214 Ensures all required variables are present before rendering.
215 """
217 REQUIRED_VARIABLES = {
218 "PROJECT_NAME": str,
219 "PROJECT_OWNER": str,
220 "CODEBASE_LANGUAGE": str,
221 "SPEC_DIR": str,
222 "DOCS_DIR": str,
223 "TEST_DIR": str,
224 "CONVERSATION_LANGUAGE": str,
225 }
227 OPTIONAL_VARIABLES: Dict[str, Any] = {
228 "PROJECT_DESCRIPTION": (str, type(None)),
229 "PROJECT_MODE": str,
230 "ENABLE_TRUST_5": bool,
231 "ENABLE_ALFRED_COMMANDS": bool,
232 "CONVERSATION_LANGUAGE": str,
233 "CONVERSATION_LANGUAGE_NAME": str,
234 "USER_NAME": (str, type(None)),
235 }
237 @classmethod
238 def validate(cls, variables: Dict[str, Any]) -> tuple[bool, list[str]]:
239 """
240 Validate template variables.
242 Args:
243 variables: Dictionary of variables to validate
245 Returns:
246 Tuple of (is_valid, list_of_errors)
247 """
248 errors = []
250 # Check required variables
251 for var_name, var_type in cls.REQUIRED_VARIABLES.items():
252 if var_name not in variables:
253 errors.append(f"Missing required variable: {var_name}")
254 elif not isinstance(variables[var_name], var_type):
255 actual_type = type(variables[var_name]).__name__
256 errors.append(
257 f"Invalid type for {var_name}: "
258 f"expected {var_type.__name__}, got {actual_type}"
259 )
261 # Check optional variables (if present)
262 for var_name, var_type in cls.OPTIONAL_VARIABLES.items():
263 if var_name in variables:
264 if not isinstance(variables[var_name], var_type):
265 if isinstance(var_type, tuple):
266 type_names = " or ".join(
267 getattr(t, "__name__", str(t)) for t in var_type
268 )
269 else:
270 type_names = getattr(var_type, "__name__", str(var_type))
271 actual_type = type(variables[var_name]).__name__
272 errors.append(
273 f"Invalid type for {var_name}: "
274 f"expected {type_names}, got {actual_type}"
275 )
277 return len(errors) == 0, errors