Coverage for src / rhiza / cli.py: 100%

79 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-02 07:04 +0000

1"""Rhiza command-line interface (CLI). 

2 

3This module defines the Typer application entry points exposed by Rhiza. 

4Commands are thin wrappers around implementations in `rhiza.commands.*`. 

5""" 

6 

7import subprocess # nosec B404 

8from collections.abc import Iterator 

9from contextlib import contextmanager 

10from pathlib import Path 

11from typing import Annotated 

12 

13import typer 

14import yaml 

15 

16from rhiza import __version__ 

17from rhiza.commands import init as init_cmd 

18from rhiza.commands import validate as validate_cmd 

19from rhiza.commands.list_repos import list_repos as list_repos_cmd 

20from rhiza.commands.migrate import migrate as migrate_cmd 

21from rhiza.commands.status import status as status_cmd 

22from rhiza.commands.summarise import summarise as summarise_cmd 

23from rhiza.commands.sync import sync as sync_cmd 

24from rhiza.commands.tree import tree as tree_cmd 

25from rhiza.commands.uninstall import uninstall as uninstall_cmd 

26 

27 

28@contextmanager 

29def _exit_on_error(*exc_types: type[BaseException]) -> Iterator[None]: 

30 """Context manager that catches specified exceptions and exits with code 1. 

31 

32 Args: 

33 *exc_types: Exception types to catch. Defaults to catching Exception 

34 if none are provided. 

35 """ 

36 _types: tuple[type[BaseException], ...] = exc_types if exc_types else (Exception,) 

37 try: 

38 yield 

39 except _types: 

40 raise typer.Exit(code=1) from None 

41 

42 

43app = typer.Typer( 

44 help=( 

45 """ 

46 Rhiza - Manage reusable configuration templates for Python projects 

47 

48 https://jebel-quant.github.io/rhiza-cli/ 

49 """ 

50 ), 

51 add_completion=True, 

52) 

53 

54 

55def version_callback(value: bool) -> None: 

56 """Print version information and exit. 

57 

58 Args: 

59 value: Whether the --version flag was provided. 

60 

61 Raises: 

62 typer.Exit: Always exits after printing version. 

63 """ 

64 if value: 

65 typer.echo(f"rhiza version {__version__}") 

66 raise typer.Exit() 

67 

68 

69@app.callback() 

70def main( 

71 version: bool = typer.Option( 

72 False, 

73 "--version", 

74 "-v", 

75 help="Show version and exit", 

76 callback=version_callback, 

77 is_eager=True, 

78 ), 

79) -> None: 

80 """Rhiza CLI main callback. 

81 

82 This callback is executed before any command. It handles global options 

83 like --version. 

84 

85 Args: 

86 version: Version flag (handled by callback). 

87 """ 

88 

89 

90@app.command() 

91def init( 

92 target: Annotated[ 

93 Path, 

94 typer.Argument( 

95 exists=True, 

96 file_okay=False, 

97 dir_okay=True, 

98 help="Target directory (defaults to current directory)", 

99 ), 

100 ] = Path("."), 

101 project_name: str = typer.Option( 

102 None, 

103 "--project-name", 

104 help="Custom project name (defaults to directory name)", 

105 ), 

106 package_name: str = typer.Option( 

107 None, 

108 "--package-name", 

109 help="Custom package name (defaults to normalized project name)", 

110 ), 

111 with_dev_dependencies: bool = typer.Option( 

112 False, 

113 "--with-dev-dependencies", 

114 help="Include development dependencies in pyproject.toml", 

115 ), 

116 git_host: str = typer.Option( 

117 None, 

118 "--git-host", 

119 help="Target Git hosting platform (github or gitlab). Determines which CI/CD files to include. " 

120 "If not provided, will prompt interactively.", 

121 ), 

122 language: str = typer.Option( 

123 "python", 

124 "--language", 

125 help="Programming language for the project (python, go, etc.). Defaults to 'python'.", 

126 ), 

127 template_repository: str = typer.Option( 

128 None, 

129 "--template-repository", 

130 help=( 

131 "Custom template repository (format: owner/repo). " 

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

133 ), 

134 ), 

135 template_branch: str = typer.Option( 

136 None, 

137 "--template-branch", 

138 help="Custom template branch. Defaults to 'main'.", 

139 ), 

140 path_to_template: Annotated[ 

141 Path | None, 

142 typer.Option( 

143 "--path-to-template", 

144 help=( 

145 "Directory where template.yml will be created " 

146 "(defaults to <TARGET>/.rhiza). " 

147 "Use '.' to keep the file in the project root." 

148 ), 

149 ), 

150 ] = None, 

151) -> None: 

152 r"""Initialize or validate .rhiza/template.yml. 

153 

154 Creates a default `.rhiza/template.yml` configuration file if one 

155 doesn't exist, or validates the existing configuration. 

156 

157 The default template includes common project files based on the language. 

158 The --git-host option determines which CI/CD configuration to include: 

159 - github: includes .github folder (GitHub Actions workflows) 

160 - gitlab: includes .gitlab-ci.yml (GitLab CI configuration) 

161 

162 The --language option determines the project type and files created: 

163 - python: creates pyproject.toml, src/, and Python project structure 

164 - go: creates minimal structure (you'll need to run 'go mod init') 

165 

166 Examples: 

167 rhiza init 

168 rhiza init --language go 

169 rhiza init --language python --git-host github 

170 rhiza init --git-host gitlab 

171 rhiza init --template-repository myorg/my-templates 

172 rhiza init --template-repository myorg/my-templates --template-branch develop 

173 rhiza init /path/to/project 

174 rhiza init .. --language go 

175 rhiza init --path-to-template /custom/rhiza 

176 rhiza init --path-to-template . 

177 """ 

178 template_file = None 

179 if path_to_template is not None: 

180 template_file = path_to_template / "template.yml" 

181 if not init_cmd( 

182 target, 

183 project_name=project_name, 

184 package_name=package_name, 

185 with_dev_dependencies=with_dev_dependencies, 

186 git_host=git_host, 

187 language=language, 

188 template_repository=template_repository, 

189 template_branch=template_branch, 

190 template_file=template_file, 

191 ): 

192 raise typer.Exit(code=1) 

193 

194 

195@app.command() 

196def sync( 

197 target: Annotated[ 

198 Path, 

199 typer.Argument( 

200 exists=True, 

201 file_okay=False, 

202 dir_okay=True, 

203 help="Target git repository (defaults to current directory)", 

204 ), 

205 ] = Path("."), 

206 branch: str = typer.Option("main", "--branch", "-b", help="Rhiza branch to use"), 

207 target_branch: str = typer.Option( 

208 None, 

209 "--target-branch", 

210 "--checkout-branch", 

211 help="Create and checkout a new branch in the target repository for changes", 

212 ), 

213 strategy: str = typer.Option( 

214 "merge", 

215 "--strategy", 

216 "-s", 

217 help="Sync strategy: 'merge' (3-way merge preserving local changes) or 'diff' (dry-run showing changes)", 

218 ), 

219 path_to_template: Annotated[ 

220 Path | None, 

221 typer.Option( 

222 "--path-to-template", 

223 help=( 

224 "Directory containing template.yml and where template.lock will be written " 

225 "(defaults to <TARGET>/.rhiza). " 

226 "Use '.' to keep both files in the project root." 

227 ), 

228 ), 

229 ] = None, 

230) -> None: 

231 r"""Sync templates using diff/merge, preserving local customisations. 

232 

233 This is the primary command for keeping your project up to date with 

234 the template repository. It replaces the deprecated ``materialize`` command. 

235 

236 On **first sync** (no lock file) the command copies all template files and 

237 records the current template HEAD in `.rhiza/template.lock`. On 

238 **subsequent syncs** it computes the diff between the last-synced commit 

239 and the current HEAD then applies it via ``git apply -3`` so local edits 

240 are preserved. 

241 

242 The command tracks the last-synced template commit in 

243 `.rhiza/template.lock`. On subsequent syncs it computes the diff 

244 between two snapshots of the template: 

245 

246 \b 

247 - base: the template at the last-synced commit 

248 - upstream: the template at the current branch HEAD 

249 - local: the file in your project (possibly customised) 

250 

251 Files that changed only upstream are updated automatically. 

252 Files that changed only locally are left untouched. 

253 Files that changed in both places are merged; conflicts are marked 

254 with standard git conflict markers for manual resolution. 

255 

256 Strategies: 

257 \b 

258 - merge: 3-way merge preserving local changes (default) 

259 - diff: dry-run showing what would change 

260 

261 Examples: 

262 rhiza sync 

263 rhiza sync --strategy diff 

264 rhiza sync --branch develop 

265 rhiza sync --target-branch feature/update-templates 

266 rhiza sync --path-to-template /custom/rhiza 

267 rhiza sync --path-to-template . 

268 """ 

269 if strategy not in ("merge", "diff"): 

270 typer.echo(f"Unknown strategy: {strategy}. Must be 'merge' or 'diff'.") 

271 raise typer.Exit(code=1) 

272 template_file = lock_file = None 

273 if path_to_template is not None: 

274 template_file = path_to_template / "template.yml" 

275 lock_file = path_to_template / "template.lock" 

276 with _exit_on_error(subprocess.CalledProcessError, RuntimeError, ValueError): 

277 sync_cmd(target, branch, target_branch, strategy, template_file=template_file, lock_file=lock_file) 

278 

279 

280@app.command() 

281def status( 

282 target: Annotated[ 

283 Path, 

284 typer.Argument( 

285 help="Path to target repository", 

286 ), 

287 ] = Path("."), 

288) -> None: 

289 """Show the current sync status from template.lock.""" 

290 with _exit_on_error(FileNotFoundError, ValueError, TypeError, yaml.YAMLError): 

291 status_cmd(target.resolve()) 

292 

293 

294@app.command() 

295def tree( 

296 target: Annotated[ 

297 Path, 

298 typer.Argument( 

299 help="Path to target repository", 

300 ), 

301 ] = Path("."), 

302) -> None: 

303 r"""List files managed by Rhiza in a tree-style view. 

304 

305 Reads .rhiza/template.lock and displays the files that were synced 

306 from the template repository as a directory tree. 

307 

308 Examples: 

309 rhiza tree 

310 rhiza tree /path/to/project 

311 """ 

312 with _exit_on_error(FileNotFoundError, ValueError, TypeError, yaml.YAMLError): 

313 tree_cmd(target.resolve()) 

314 

315 

316@app.command() 

317def validate( 

318 target: Annotated[ 

319 Path, 

320 typer.Argument( 

321 exists=True, 

322 file_okay=False, 

323 dir_okay=True, 

324 help="Target git repository (defaults to current directory)", 

325 ), 

326 ] = Path("."), 

327 path_to_template: Annotated[ 

328 Path | None, 

329 typer.Option( 

330 "--path-to-template", 

331 help=( 

332 "Directory containing template.yml " 

333 "(defaults to <TARGET>/.rhiza). " 

334 "Use '.' to keep the file in the project root." 

335 ), 

336 ), 

337 ] = None, 

338) -> None: 

339 r"""Validate Rhiza template configuration. 

340 

341 Validates the .rhiza/template.yml file to ensure it is syntactically 

342 correct and semantically valid. 

343 

344 Performs comprehensive validation: 

345 - Checks if template.yml exists 

346 - Validates YAML syntax 

347 - Verifies required fields are present (template-repository, include) 

348 - Validates field types and formats 

349 - Ensures repository name follows owner/repo format 

350 - Confirms include paths are not empty 

351 

352 

353 Returns exit code 0 on success, 1 on validation failure. 

354 

355 Examples: 

356 rhiza validate 

357 rhiza validate /path/to/project 

358 rhiza validate .. 

359 rhiza validate --path-to-template /custom/rhiza 

360 rhiza validate --path-to-template . 

361 """ 

362 template_file = None 

363 if path_to_template is not None: 

364 template_file = path_to_template / "template.yml" 

365 if not validate_cmd(target, template_file=template_file): 

366 raise typer.Exit(code=1) 

367 

368 

369@app.command() 

370def migrate( 

371 target: Annotated[ 

372 Path, 

373 typer.Argument( 

374 exists=True, 

375 file_okay=False, 

376 dir_okay=True, 

377 help="Target git repository (defaults to current directory)", 

378 ), 

379 ] = Path("."), 

380) -> None: 

381 r"""Migrate project to the new .rhiza folder structure. 

382 

383 This command helps transition projects to use the new `.rhiza/` folder 

384 structure for storing Rhiza state and configuration files. It performs 

385 the following migrations: 

386 

387 - Creates the `.rhiza/` directory in the project root 

388 - Moves `.github/rhiza/template.yml` or `.github/template.yml` to `.rhiza/template.yml` 

389 - Moves `.rhiza.history` to `.rhiza/history` 

390 

391 The new `.rhiza/` folder structure separates Rhiza's state and configuration 

392 from the `.github/` directory, providing better organization. 

393 

394 If files already exist in `.rhiza/`, the migration will skip them and leave 

395 the old files in place. You can manually remove old files after verifying 

396 the migration was successful. 

397 

398 Examples: 

399 rhiza migrate 

400 rhiza migrate /path/to/project 

401 """ 

402 migrate_cmd(target) 

403 

404 

405@app.command(name="list") 

406def list_repos( 

407 topic: str = typer.Option( 

408 "rhiza", 

409 "--topic", 

410 "-t", 

411 help="GitHub topic to search for (default: 'rhiza')", 

412 ), 

413) -> None: 

414 r"""List GitHub repositories tagged with a given topic. 

415 

416 Queries the GitHub Search API for repositories tagged with the 

417 specified topic and displays them in a formatted table with the 

418 repository name, description, and last-updated date. 

419 

420 Set the ``GITHUB_TOKEN`` environment variable to avoid API rate limits. 

421 

422 Examples: 

423 rhiza list 

424 rhiza list --topic rhiza-go 

425 """ 

426 if not list_repos_cmd(topic): 

427 raise typer.Exit(code=1) 

428 

429 

430@app.command() 

431def uninstall( 

432 target: Annotated[ 

433 Path, 

434 typer.Argument( 

435 exists=True, 

436 file_okay=False, 

437 dir_okay=True, 

438 help="Target git repository (defaults to current directory)", 

439 ), 

440 ] = Path("."), 

441 force: bool = typer.Option( 

442 False, 

443 "--force", 

444 "-y", 

445 help="Skip confirmation prompt and proceed with deletion", 

446 ), 

447) -> None: 

448 r"""Remove all Rhiza-managed files from the repository. 

449 

450 Reads the `.rhiza/history` file and removes all files that were 

451 previously synced by Rhiza templates. This provides a clean 

452 way to uninstall all template-managed files from a project. 

453 

454 The command will: 

455 - Read the list of files from `.rhiza.history` 

456 - Prompt for confirmation (unless --force is used) 

457 - Delete all listed files that exist 

458 - Remove empty directories left behind 

459 - Delete the `.rhiza.history` file itself 

460 

461 Use this command when you want to completely remove Rhiza templates 

462 from your project. 

463 

464 Examples: 

465 rhiza uninstall 

466 rhiza uninstall --force 

467 rhiza uninstall /path/to/project 

468 rhiza uninstall /path/to/project -y 

469 """ 

470 with _exit_on_error(RuntimeError): 

471 uninstall_cmd(target, force) 

472 

473 

474@app.command() 

475def summarise( 

476 target: Annotated[ 

477 Path, 

478 typer.Argument( 

479 exists=True, 

480 file_okay=False, 

481 dir_okay=True, 

482 help="Target git repository (defaults to current directory)", 

483 ), 

484 ] = Path("."), 

485 output: Annotated[ 

486 Path | None, 

487 typer.Option( 

488 "--output", 

489 "-o", 

490 help="Output file path (defaults to stdout)", 

491 ), 

492 ] = None, 

493) -> None: 

494 r"""Generate a summary of staged changes for PR descriptions. 

495 

496 Analyzes staged git changes and generates a structured PR description 

497 that includes: 

498 

499 - Summary statistics (files added/modified/deleted) 

500 - Changes categorized by type (workflows, configs, docs, tests, etc.) 

501 - Template repository information 

502 - Last sync date 

503 

504 This is useful when creating pull requests after running `rhiza sync` 

505 to provide reviewers with a clear overview of what changed. 

506 

507 Examples: 

508 rhiza summarise 

509 rhiza summarise --output pr-description.md 

510 rhiza summarise /path/to/project -o description.md 

511 

512 Typical workflow: 

513 rhiza sync 

514 git add . 

515 rhiza summarise --output pr-body.md 

516 gh pr create --title "chore: Sync with rhiza" --body-file pr-body.md 

517 """ 

518 with _exit_on_error(RuntimeError): 

519 summarise_cmd(target, output)