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

1""" 

2Template engine for parameterizing GitHub templates and other configuration files. 

3 

4Supports Jinja2-style templating with variable substitution and conditional sections. 

5Enables users to customize MoAI-ADK templates for their own projects. 

6 

7""" 

8 

9from pathlib import Path 

10from typing import Any, Dict, Optional 

11 

12from jinja2 import ( 

13 Environment, 

14 FileSystemLoader, 

15 StrictUndefined, 

16 TemplateNotFound, 

17 TemplateRuntimeError, 

18 TemplateSyntaxError, 

19 Undefined, 

20) 

21 

22 

23class TemplateEngine: 

24 """ 

25 Jinja2-based template engine for MoAI-ADK configuration and GitHub templates. 

26 

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

32 

33 def __init__(self, strict_undefined: bool = True): 

34 """ 

35 Initialize the template engine. 

36 

37 Args: 

38 strict_undefined: If True, raise error on undefined variables (default: True). 

39 If False, render undefined variables as empty strings. 

40 

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 

47 

48 def render_string(self, template_string: str, variables: Dict[str, Any]) -> str: 

49 """ 

50 Render a Jinja2 template string with provided variables. 

51 

52 Args: 

53 template_string: The template content as a string 

54 variables: Dictionary of variables to substitute 

55 

56 Returns: 

57 Rendered template string 

58 

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

73 

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. 

82 

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 

87 

88 Returns: 

89 Rendered template content 

90 

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

98 

99 template_dir = template_path.parent 

100 template_name = template_path.name 

101 

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) 

111 

112 if output_path: 

113 output_path.parent.mkdir(parents=True, exist_ok=True) 

114 output_path.write_text(rendered, encoding="utf-8") 

115 

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

123 

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. 

133 

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) 

139 

140 Returns: 

141 Dictionary mapping input paths to rendered content 

142 

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

148 

149 results = {} 

150 output_dir.mkdir(parents=True, exist_ok=True) 

151 

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 

156 

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

162 

163 return results 

164 

165 @staticmethod 

166 def get_default_variables(config: Dict[str, Any]) -> Dict[str, Any]: 

167 """ 

168 Extract template variables from project configuration. 

169 

170 Args: 

171 config: Project configuration dictionary (from .moai/config/config.json) 

172 

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", {}) 

179 

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 } 

209 

210 

211class TemplateVariableValidator: 

212 """ 

213 Validates template variables for completeness and correctness. 

214 Ensures all required variables are present before rendering. 

215 """ 

216 

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 } 

226 

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 } 

236 

237 @classmethod 

238 def validate(cls, variables: Dict[str, Any]) -> tuple[bool, list[str]]: 

239 """ 

240 Validate template variables. 

241 

242 Args: 

243 variables: Dictionary of variables to validate 

244 

245 Returns: 

246 Tuple of (is_valid, list_of_errors) 

247 """ 

248 errors = [] 

249 

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 ) 

260 

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 ) 

276 

277 return len(errors) == 0, errors