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

202 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-02 07:04 +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 subprocess # nosec B404 

12import sys 

13import urllib.error 

14from pathlib import Path 

15 

16import typer 

17from jinja2 import Template 

18from loguru import logger 

19 

20from rhiza.commands.list_repos import _DESC_WIDTH, _fetch_repos 

21from rhiza.commands.validate import validate 

22from rhiza.models import GitContext, GitHost, RhizaTemplate 

23 

24 

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

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

27 

28 Args: 

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

30 

31 Returns: 

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

33 """ 

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

35 if name[0].isdigit(): 

36 name = f"_{name}" 

37 if keyword.iskeyword(name): 

38 name = f"{name}_" 

39 return name 

40 

41 

42def _validate_git_host(git_host: str | None) -> GitHost | None: 

43 """Validate git_host parameter. 

44 

45 Args: 

46 git_host: Git hosting platform. 

47 

48 Returns: 

49 Validated GitHost enum value or None. 

50 

51 Raises: 

52 ValueError: If git_host is invalid. 

53 """ 

54 if git_host is None: 

55 return None 

56 try: 

57 return GitHost(git_host.lower()) 

58 except ValueError: 

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

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

61 

62 

63def _check_template_repository_reachable(template_repository: str, git_host: GitHost | str = GitHost.GITHUB) -> bool: 

64 """Check if the template repository is reachable via git ls-remote. 

65 

66 Args: 

67 template_repository: Repository in 'owner/repo' format. 

68 git_host: Git hosting platform ('github' or 'gitlab'). Defaults to 'github'. 

69 

70 Returns: 

71 True if the repository is reachable, False otherwise. 

72 """ 

73 host_urls = { 

74 GitHost.GITHUB: "https://github.com", 

75 GitHost.GITLAB: "https://gitlab.com", 

76 } 

77 base_url = host_urls.get(git_host, "https://github.com") 

78 repo_url = f"{base_url}/{template_repository}" 

79 

80 logger.debug(f"Checking reachability of template repository: {repo_url}") 

81 try: 

82 git_ctx = GitContext.default() 

83 result = subprocess.run( # nosec B603 # noqa: S603 

84 [git_ctx.executable, "ls-remote", "--exit-code", repo_url], 

85 capture_output=True, 

86 timeout=30, 

87 env=git_ctx.env, 

88 ) 

89 if result.returncode == 0: 

90 logger.success(f"Template repository is reachable: {template_repository}") 

91 return True 

92 else: 

93 logger.error(f"Template repository '{template_repository}' is not accessible at {repo_url}") 

94 logger.error("Please check that the repository exists and you have access to it.") 

95 return False 

96 except subprocess.TimeoutExpired: 

97 logger.error(f"Timed out while checking repository reachability: {repo_url}") 

98 logger.error("Please check your network connection and try again.") 

99 return False 

100 except RuntimeError as e: 

101 logger.warning(f"Could not verify template repository reachability: {e}") 

102 return True # Don't block init if git is unavailable 

103 

104 

105def _prompt_git_host() -> GitHost: 

106 """Prompt user for git hosting platform. 

107 

108 Returns: 

109 Git hosting platform choice as a GitHost enum value. 

110 """ 

111 if sys.stdin.isatty(): 

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

113 git_host = typer.prompt( 

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

115 type=str, 

116 default="github", 

117 ).lower() 

118 

119 while git_host not in GitHost._value2member_map_: 

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

121 git_host = typer.prompt( 

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

123 type=str, 

124 default="github", 

125 ).lower() 

126 else: 

127 git_host = "github" 

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

129 

130 return GitHost(git_host) 

131 

132 

133def _prompt_template_repository() -> str | None: 

134 """Prompt the user to select a template repository from a list of rhiza-tagged repos. 

135 

136 Fetches repositories tagged with 'rhiza' from the GitHub API and presents 

137 them as a numbered list. In non-interactive or offline scenarios the function 

138 returns None so the caller falls back to the language default. 

139 

140 Returns: 

141 The selected repository in 'owner/repo' format, or None if the user 

142 accepts the default or selection is not possible. 

143 """ 

144 if not sys.stdin.isatty(): 

145 logger.debug("Non-interactive mode detected, skipping template repository selection") 

146 return None 

147 

148 try: 

149 repos = _fetch_repos() 

150 except urllib.error.URLError as exc: 

151 logger.debug(f"Could not fetch repository list: {exc}") 

152 return None 

153 

154 if not repos: 

155 return None 

156 

157 # Display a compact numbered list 

158 typer.echo("\nAvailable template repositories:") 

159 for i, repo in enumerate(repos, start=1): 

160 desc = repo.description[:_DESC_WIDTH] if repo.description else "" 

161 typer.echo(f" {i:>2} {repo.full_name:<30} {desc}") 

162 

163 typer.echo("") 

164 selection = typer.prompt( 

165 "Select a template repository by number, or press Enter to use the default", 

166 default="", 

167 ).strip() 

168 

169 if not selection: 

170 return None 

171 

172 try: 

173 idx = int(selection) 

174 if 1 <= idx <= len(repos): 

175 chosen = repos[idx - 1].full_name 

176 logger.info(f"Selected template repository: {chosen}") 

177 return chosen 

178 else: 

179 logger.warning(f"Invalid selection '{idx}', using default repository") 

180 return None 

181 except ValueError: 

182 logger.warning(f"Invalid input '{selection}', using default repository") 

183 return None 

184 

185 

186def _get_default_templates_for_host(git_host: GitHost | str) -> list[str]: 

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

188 

189 Args: 

190 git_host: Git hosting platform. 

191 

192 Returns: 

193 List of template names. 

194 """ 

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

196 if git_host == GitHost.GITLAB: 

197 return [*common, "gitlab"] 

198 else: 

199 return [*common, "github"] 

200 

201 

202def _display_path(path: Path, target: Path) -> Path: 

203 """Return *path* relative to *target* when possible, otherwise the absolute path. 

204 

205 Args: 

206 path: Path to display. 

207 target: Base directory used as the reference point. 

208 

209 Returns: 

210 A relative or absolute Path suitable for log messages. 

211 """ 

212 return path.relative_to(target) if path.is_relative_to(target) else path 

213 

214 

215def _create_template_file( 

216 target: Path, 

217 git_host: GitHost | str, 

218 language: str = "python", 

219 template_repository: str | None = None, 

220 template_branch: str | None = None, 

221 template_file: Path | None = None, 

222) -> None: 

223 """Create default template.yml file. 

224 

225 Args: 

226 target: Target repository path. 

227 git_host: Git hosting platform. 

228 language: Programming language for the project (default: python). 

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

230 template_branch: Custom template branch. 

231 template_file: Optional explicit path to write template.yml. When 

232 ``None`` the default ``<target>/.rhiza/template.yml`` is used. 

233 """ 

234 if template_file is None: 

235 rhiza_dir = target / ".rhiza" 

236 template_file = rhiza_dir / "template.yml" 

237 

238 if template_file.exists(): 

239 return 

240 

241 template_file.parent.mkdir(parents=True, exist_ok=True) 

242 logger.info(f"Creating default {_display_path(template_file, target)}") 

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

244 

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

246 if template_repository: 

247 repo = template_repository 

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

249 else: 

250 # Default repositories by language 

251 repo = "jebel-quant/rhiza-go" if language == "go" else "jebel-quant/rhiza" 

252 logger.debug(f"Using default repository for {language}: {repo}") 

253 

254 branch = template_branch or "main" 

255 

256 # Log when custom values are used 

257 if template_branch: 

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

259 

260 templates = _get_default_templates_for_host(git_host) 

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

262 default_template = RhizaTemplate( 

263 template_repository=repo, 

264 template_branch=branch, 

265 language=language, 

266 templates=templates, 

267 ) 

268 

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

270 default_template.to_yaml(template_file) 

271 

272 logger.success(f"✓ Created {_display_path(template_file, target)}") 

273 logger.info(""" 

274Next steps: 

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

276 2. Run 'uvx rhiza sync' to inject templates into your repository 

277""") 

278 

279 

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

281 """Create basic Python package structure. 

282 

283 Args: 

284 target: Target repository path. 

285 project_name: Project name. 

286 package_name: Package name. 

287 """ 

288 src_folder = target / "src" / package_name 

289 test_folder = target / "tests" 

290 

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

292 return 

293 

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

295 src_folder.mkdir(parents=True) 

296 

297 logger.info(f"Creating test folder: {test_folder}") 

298 test_folder.mkdir(parents=True) 

299 

300 # Create __init__.py 

301 init_file = src_folder / "__init__.py" 

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

303 init_file.touch() 

304 

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

306 template = Template(template_content) 

307 code = template.render(project_name=project_name) 

308 init_file.write_text(code) 

309 

310 # Create main.py 

311 main_file = src_folder / "main.py" 

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

313 main_file.touch() 

314 

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

316 template = Template(template_content) 

317 code = template.render(project_name=project_name) 

318 main_file.write_text(code) 

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

320 

321 # Create main.py 

322 test_file = test_folder / "test_main.py" 

323 logger.debug(f"Creating {test_file} with example code") 

324 test_file.touch() 

325 

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

327 template = Template(template_content) 

328 code = template.render(project_name=project_name) 

329 test_file.write_text(code) 

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

331 

332 

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

334 """Create pyproject.toml file. 

335 

336 Args: 

337 target: Target repository path. 

338 project_name: Project name. 

339 package_name: Package name. 

340 with_dev_dependencies: Whether to include dev dependencies. 

341 """ 

342 pyproject_file = target / "pyproject.toml" 

343 if pyproject_file.exists(): 

344 return 

345 

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

347 pyproject_file.touch() 

348 

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

350 template = Template(template_content) 

351 code = template.render( 

352 project_name=project_name, 

353 package_name=package_name, 

354 with_dev_dependencies=with_dev_dependencies, 

355 ) 

356 pyproject_file.write_text(code) 

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

358 

359 

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

361 """Create README.md file. 

362 

363 Args: 

364 target: Target repository path. 

365 """ 

366 readme_file = target / "README.md" 

367 if readme_file.exists(): 

368 return 

369 

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

371 readme_file.touch() 

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

373 

374 

375def init( 

376 target: Path, 

377 project_name: str | None = None, 

378 package_name: str | None = None, 

379 with_dev_dependencies: bool = False, 

380 git_host: str | None = None, 

381 language: str = "python", 

382 template_repository: str | None = None, 

383 template_branch: str | None = None, 

384 template_file: Path | None = None, 

385) -> bool: 

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

387 

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

389 or validates an existing one. 

390 

391 Args: 

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

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

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

395 with_dev_dependencies: Include development dependencies in pyproject.toml. 

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

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

398 language: Programming language for the project (default: python). 

399 Supported: python, go. Determines which project files to create. 

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

401 Defaults to 'jebel-quant/rhiza' for Python or 'jebel-quant/rhiza-go' for Go. 

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

403 template_file: Optional explicit path to write template.yml. When 

404 ``None`` the default ``<target>/.rhiza/template.yml`` is used. 

405 

406 Returns: 

407 bool: True if validation passes, False otherwise. 

408 """ 

409 target = target.resolve() 

410 git_host = _validate_git_host(git_host) 

411 

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

413 logger.info(f"Project language: {language}") 

414 

415 # Create .rhiza directory (always; project structure lives there regardless of 

416 # where template.yml is placed) 

417 rhiza_dir = target / ".rhiza" 

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

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

420 

421 # Determine git host 

422 if git_host is None: 

423 git_host = _prompt_git_host() 

424 

425 # When no template repository is specified and no config file exists yet, 

426 # offer the user an interactive selection from discovered rhiza repos. 

427 resolved_template_file = template_file if template_file is not None else target / ".rhiza" / "template.yml" 

428 if template_repository is None and not resolved_template_file.exists(): 

429 template_repository = _prompt_template_repository() 

430 

431 # Validate template repository reachability early if a custom one is specified 

432 if template_repository is not None and not _check_template_repository_reachable(template_repository, git_host): 

433 return False 

434 

435 # Create template file with language 

436 _create_template_file(target, git_host, language, template_repository, template_branch, template_file) 

437 

438 # Bootstrap project structure based on language 

439 if language == "python": 

440 # Python-specific setup 

441 if project_name is None: 

442 project_name = target.name 

443 if package_name is None: 

444 package_name = _normalize_package_name(project_name) 

445 

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

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

448 

449 _create_python_package(target, project_name, package_name) 

450 _create_pyproject_toml(target, project_name, package_name, with_dev_dependencies) 

451 _create_readme(target) 

452 elif language == "go": 

453 # Go-specific setup - just create README, user should run go mod init 

454 _create_readme(target) 

455 logger.info("For Go projects, run 'go mod init <module-name>' to initialize the module") 

456 else: 

457 # Unknown language - just create README 

458 logger.warning(f"Unknown language '{language}', creating minimal structure") 

459 _create_readme(target) 

460 

461 # Validate the template file 

462 logger.debug("Validating template configuration") 

463 return validate(target, template_file=template_file)