Coverage for src / rhiza / commands / init.py: 100%

119 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-02-12 20:13 +0000

1"""Command to initialize or validate .rhiza/template.yml. 

2 

3This module provides the init command that creates or validates the 

4.rhiza/template.yml file, which defines where templates come from 

5and what paths are governed by Rhiza. 

6""" 

7 

8import importlib.resources 

9import keyword 

10import re 

11import sys 

12from pathlib import Path 

13 

14import typer 

15from jinja2 import Template 

16from loguru import logger 

17 

18from rhiza.commands.validate import validate 

19from rhiza.models import RhizaTemplate 

20 

21 

22def _normalize_package_name(name: str) -> str: 

23 """Normalize a string into a valid Python package name. 

24 

25 Args: 

26 name: The input string (e.g., project name). 

27 

28 Returns: 

29 A valid Python identifier safe for use as a package name. 

30 """ 

31 name = re.sub(r"[^a-zA-Z0-9_]", "_", name) 

32 if name[0].isdigit(): 

33 name = f"_{name}" 

34 if keyword.iskeyword(name): 

35 name = f"{name}_" 

36 return name 

37 

38 

39def _validate_git_host(git_host: str | None) -> str | None: 

40 """Validate git_host parameter. 

41 

42 Args: 

43 git_host: Git hosting platform. 

44 

45 Returns: 

46 Validated git_host or None. 

47 

48 Raises: 

49 ValueError: If git_host is invalid. 

50 """ 

51 if git_host is not None: 

52 git_host = git_host.lower() 

53 if git_host not in ["github", "gitlab"]: 

54 logger.error(f"Invalid git-host: {git_host}. Must be 'github' or 'gitlab'") 

55 raise ValueError(f"Invalid git-host: {git_host}. Must be 'github' or 'gitlab'") # noqa: TRY003 

56 return git_host 

57 

58 

59def _prompt_git_host() -> str: 

60 """Prompt user for git hosting platform. 

61 

62 Returns: 

63 Git hosting platform choice. 

64 """ 

65 if sys.stdin.isatty(): 

66 logger.info("Where will your project be hosted?") 

67 git_host = typer.prompt( 

68 "Target Git hosting platform (github/gitlab)", 

69 type=str, 

70 default="github", 

71 ).lower() 

72 

73 while git_host not in ["github", "gitlab"]: 

74 logger.warning(f"Invalid choice: {git_host}. Please choose 'github' or 'gitlab'") 

75 git_host = typer.prompt( 

76 "Target Git hosting platform (github/gitlab)", 

77 type=str, 

78 default="github", 

79 ).lower() 

80 else: 

81 git_host = "github" 

82 logger.debug("Non-interactive mode detected, defaulting to github") 

83 

84 return str(git_host) 

85 

86 

87def _get_default_templates_for_host(git_host: str) -> list[str]: 

88 """Get default templates based on git hosting platform. 

89 

90 Args: 

91 git_host: Git hosting platform. 

92 

93 Returns: 

94 List of template names. 

95 """ 

96 common = ["core", "tests", "book", "marimo", "presentation"] 

97 if git_host == "gitlab": 

98 return [*common, "gitlab"] 

99 else: 

100 return [*common, "github"] 

101 

102 

103# def _get_include_paths_for_host(git_host: str) -> list[str]: 

104# """Get include paths based on git hosting platform (legacy, path-based). 

105# 

106# Args: 

107# git_host: Git hosting platform. 

108# 

109# Returns: 

110# List of include paths. 

111# """ 

112# if git_host == "gitlab": 

113# return [ 

114# ".rhiza", 

115# ".gitlab", 

116# ".gitlab-ci.yml", 

117# ".editorconfig", 

118# ".gitignore", 

119# ".pre-commit-config.yaml", 

120# "ruff.toml", 

121# "Makefile", 

122# "pytest.ini", 

123# "book", 

124# "presentation", 

125# "tests", 

126# ] 

127# else: 

128# return [ 

129# ".rhiza", 

130# ".github", 

131# ".editorconfig", 

132# ".gitignore", 

133# ".pre-commit-config.yaml", 

134# "ruff.toml", 

135# "Makefile", 

136# "pytest.ini", 

137# "book", 

138# "presentation", 

139# "tests", 

140# ] 

141 

142 

143def _create_template_file( 

144 target: Path, 

145 git_host: str, 

146 template_repository: str | None = None, 

147 template_branch: str | None = None, 

148) -> None: 

149 """Create default template.yml file. 

150 

151 Args: 

152 target: Target repository path. 

153 git_host: Git hosting platform. 

154 template_repository: Custom template repository (format: owner/repo). 

155 template_branch: Custom template branch. 

156 """ 

157 rhiza_dir = target / ".rhiza" 

158 template_file = rhiza_dir / "template.yml" 

159 

160 if template_file.exists(): 

161 return 

162 

163 logger.info("Creating default .rhiza/template.yml") 

164 logger.debug("Using default template configuration") 

165 

166 # Use custom template repository/branch if provided, otherwise use defaults 

167 repo = template_repository or "jebel-quant/rhiza" 

168 branch = template_branch or "main" 

169 

170 # Log when custom values are used 

171 if template_repository: 

172 logger.info(f"Using custom template repository: {repo}") 

173 if template_branch: 

174 logger.info(f"Using custom template branch: {branch}") 

175 

176 templates = _get_default_templates_for_host(git_host) 

177 logger.info(f"Using template-based configuration with templates: {', '.join(templates)}") 

178 default_template = RhizaTemplate( 

179 template_repository=repo, 

180 template_branch=branch, 

181 templates=templates, 

182 ) 

183 

184 logger.debug(f"Writing default template to: {template_file}") 

185 default_template.to_yaml(template_file) 

186 

187 logger.success("✓ Created .rhiza/template.yml") 

188 logger.info(""" 

189Next steps: 

190 1. Review and customize .rhiza/template.yml to match your project needs 

191 2. Run 'rhiza materialize' to inject templates into your repository 

192""") 

193 

194 

195def _create_python_package(target: Path, project_name: str, package_name: str) -> None: 

196 """Create basic Python package structure. 

197 

198 Args: 

199 target: Target repository path. 

200 project_name: Project name. 

201 package_name: Package name. 

202 """ 

203 src_folder = target / "src" / package_name 

204 if (target / "src").exists(): 

205 return 

206 

207 logger.info(f"Creating Python package structure: {src_folder}") 

208 src_folder.mkdir(parents=True) 

209 

210 # Create __init__.py 

211 init_file = src_folder / "__init__.py" 

212 logger.debug(f"Creating {init_file}") 

213 init_file.touch() 

214 

215 template_content = importlib.resources.files("rhiza").joinpath("_templates/basic/__init__.py.jinja2").read_text() 

216 template = Template(template_content) 

217 code = template.render(project_name=project_name) 

218 init_file.write_text(code) 

219 

220 # Create main.py 

221 main_file = src_folder / "main.py" 

222 logger.debug(f"Creating {main_file} with example code") 

223 main_file.touch() 

224 

225 template_content = importlib.resources.files("rhiza").joinpath("_templates/basic/main.py.jinja2").read_text() 

226 template = Template(template_content) 

227 code = template.render(project_name=project_name) 

228 main_file.write_text(code) 

229 logger.success(f"Created Python package structure in {src_folder}") 

230 

231 

232def _create_pyproject_toml(target: Path, project_name: str, package_name: str, with_dev_dependencies: bool) -> None: 

233 """Create pyproject.toml file. 

234 

235 Args: 

236 target: Target repository path. 

237 project_name: Project name. 

238 package_name: Package name. 

239 with_dev_dependencies: Whether to include dev dependencies. 

240 """ 

241 pyproject_file = target / "pyproject.toml" 

242 if pyproject_file.exists(): 

243 return 

244 

245 logger.info("Creating pyproject.toml with basic project metadata") 

246 pyproject_file.touch() 

247 

248 template_content = importlib.resources.files("rhiza").joinpath("_templates/basic/pyproject.toml.jinja2").read_text() 

249 template = Template(template_content) 

250 code = template.render( 

251 project_name=project_name, 

252 package_name=package_name, 

253 with_dev_dependencies=with_dev_dependencies, 

254 ) 

255 pyproject_file.write_text(code) 

256 logger.success("Created pyproject.toml") 

257 

258 

259def _create_readme(target: Path) -> None: 

260 """Create README.md file. 

261 

262 Args: 

263 target: Target repository path. 

264 """ 

265 readme_file = target / "README.md" 

266 if readme_file.exists(): 

267 return 

268 

269 logger.info("Creating README.md") 

270 readme_file.touch() 

271 logger.success("Created README.md") 

272 

273 

274def init( 

275 target: Path, 

276 project_name: str | None = None, 

277 package_name: str | None = None, 

278 with_dev_dependencies: bool = False, 

279 git_host: str | None = None, 

280 template_repository: str | None = None, 

281 template_branch: str | None = None, 

282) -> bool: 

283 """Initialize or validate .rhiza/template.yml in the target repository. 

284 

285 Creates a default .rhiza/template.yml file if it doesn't exist, 

286 or validates an existing one. 

287 

288 Args: 

289 target: Path to the target directory. Defaults to the current working directory. 

290 project_name: Custom project name. Defaults to target directory name. 

291 package_name: Custom package name. Defaults to normalized project name. 

292 with_dev_dependencies: Include development dependencies in pyproject.toml. 

293 git_host: Target Git hosting platform ("github" or "gitlab"). Determines which 

294 CI/CD configuration files to include. If None, will prompt user interactively. 

295 template_repository: Custom template repository (format: owner/repo). 

296 Defaults to 'jebel-quant/rhiza'. 

297 template_branch: Custom template branch. Defaults to 'main'. 

298 

299 Returns: 

300 bool: True if validation passes, False otherwise. 

301 """ 

302 target = target.resolve() 

303 git_host = _validate_git_host(git_host) 

304 

305 logger.info(f"Initializing Rhiza configuration in: {target}") 

306 

307 # Create .rhiza directory 

308 rhiza_dir = target / ".rhiza" 

309 logger.debug(f"Ensuring directory exists: {rhiza_dir}") 

310 rhiza_dir.mkdir(parents=True, exist_ok=True) 

311 

312 # Determine git host 

313 if git_host is None: 

314 git_host = _prompt_git_host() 

315 

316 # Create template file 

317 _create_template_file(target, git_host, template_repository, template_branch) 

318 

319 # Bootstrap Python project structure 

320 if project_name is None: 

321 project_name = target.name 

322 if package_name is None: 

323 package_name = _normalize_package_name(project_name) 

324 

325 logger.debug(f"Project name: {project_name}") 

326 logger.debug(f"Package name: {package_name}") 

327 

328 _create_python_package(target, project_name, package_name) 

329 _create_pyproject_toml(target, project_name, package_name, with_dev_dependencies) 

330 _create_readme(target) 

331 

332 # Validate the template file 

333 logger.debug("Validating template configuration") 

334 return validate(target)