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

80 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-27 15:33 +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 SummariseOptions 

23from rhiza.commands.summarise import summarise as summarise_cmd 

24from rhiza.commands.sync import sync as sync_cmd 

25from rhiza.commands.tree import tree as tree_cmd 

26from rhiza.commands.uninstall import uninstall as uninstall_cmd 

27 

28 

29@contextmanager 

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

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

32 

33 Args: 

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

35 if none are provided. 

36 """ 

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

38 try: 

39 yield 

40 except _types: 

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

42 

43 

44app = typer.Typer( 

45 help=( 

46 """ 

47 Rhiza - Manage reusable configuration templates for Python projects 

48 

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

50 """ 

51 ), 

52 add_completion=True, 

53) 

54 

55 

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

57 """Print version information and exit. 

58 

59 Args: 

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

61 

62 Raises: 

63 typer.Exit: Always exits after printing version. 

64 """ 

65 if value: 

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

67 raise typer.Exit() 

68 

69 

70@app.callback() 

71def main( 

72 version: bool = typer.Option( 

73 False, 

74 "--version", 

75 "-v", 

76 help="Show version and exit", 

77 callback=version_callback, 

78 is_eager=True, 

79 ), 

80) -> None: 

81 """Rhiza CLI main callback. 

82 

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

84 like --version. 

85 

86 Args: 

87 version: Version flag (handled by callback). 

88 """ 

89 

90 

91@app.command() 

92def init( 

93 target: Annotated[ 

94 Path, 

95 typer.Argument( 

96 exists=True, 

97 file_okay=False, 

98 dir_okay=True, 

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

100 ), 

101 ] = Path("."), 

102 project_name: str = typer.Option( 

103 None, 

104 "--project-name", 

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

106 ), 

107 package_name: str = typer.Option( 

108 None, 

109 "--package-name", 

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

111 ), 

112 with_dev_dependencies: bool = typer.Option( 

113 False, 

114 "--with-dev-dependencies", 

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

116 ), 

117 git_host: str = typer.Option( 

118 None, 

119 "--git-host", 

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

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

122 ), 

123 language: str = typer.Option( 

124 "python", 

125 "--language", 

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

127 ), 

128 template_repository: str = typer.Option( 

129 None, 

130 "--template-repository", 

131 help=( 

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

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

134 ), 

135 ), 

136 template_branch: str = typer.Option( 

137 None, 

138 "--template-branch", 

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

140 ), 

141 path_to_template: Annotated[ 

142 Path | None, 

143 typer.Option( 

144 "--path-to-template", 

145 help=( 

146 "Directory where template.yml will be created " 

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

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

149 ), 

150 ), 

151 ] = None, 

152) -> None: 

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

154 

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

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

157 

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

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

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

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

162 

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

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

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

166 

167 Examples: 

168 rhiza init 

169 rhiza init --language go 

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

171 rhiza init --git-host gitlab 

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

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

174 rhiza init /path/to/project 

175 rhiza init .. --language go 

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

177 rhiza init --path-to-template . 

178 """ 

179 template_file = None 

180 if path_to_template is not None: 

181 template_file = path_to_template / "template.yml" 

182 if not init_cmd( 

183 target, 

184 project_name=project_name, 

185 package_name=package_name, 

186 with_dev_dependencies=with_dev_dependencies, 

187 git_host=git_host, 

188 language=language, 

189 template_repository=template_repository, 

190 template_branch=template_branch, 

191 template_file=template_file, 

192 ): 

193 raise typer.Exit(code=1) 

194 

195 

196@app.command() 

197def sync( 

198 target: Annotated[ 

199 Path, 

200 typer.Argument( 

201 exists=True, 

202 file_okay=False, 

203 dir_okay=True, 

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

205 ), 

206 ] = Path("."), 

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

208 target_branch: str = typer.Option( 

209 None, 

210 "--target-branch", 

211 "--checkout-branch", 

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

213 ), 

214 strategy: str = typer.Option( 

215 "merge", 

216 "--strategy", 

217 "-s", 

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

219 ), 

220 path_to_template: Annotated[ 

221 Path | None, 

222 typer.Option( 

223 "--path-to-template", 

224 help=( 

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

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

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

228 ), 

229 ), 

230 ] = None, 

231) -> None: 

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

233 

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

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

236 

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

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

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

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

241 are preserved. 

242 

243 The command tracks the last-synced template commit in 

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

245 between two snapshots of the template: 

246 

247 \b 

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

249 - upstream: the template at the current branch HEAD 

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

251 

252 Files that changed only upstream are updated automatically. 

253 Files that changed only locally are left untouched. 

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

255 with standard git conflict markers for manual resolution. 

256 

257 Strategies: 

258 \b 

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

260 - diff: dry-run showing what would change 

261 

262 Examples: 

263 rhiza sync 

264 rhiza sync --strategy diff 

265 rhiza sync --branch develop 

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

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

268 rhiza sync --path-to-template . 

269 """ 

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

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

272 raise typer.Exit(code=1) 

273 template_file = lock_file = None 

274 if path_to_template is not None: 

275 template_file = path_to_template / "template.yml" 

276 lock_file = path_to_template / "template.lock" 

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

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

279 

280 

281@app.command() 

282def status( 

283 target: Annotated[ 

284 Path, 

285 typer.Argument( 

286 help="Path to target repository", 

287 ), 

288 ] = Path("."), 

289) -> None: 

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

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

292 status_cmd(target.resolve()) 

293 

294 

295@app.command() 

296def tree( 

297 target: Annotated[ 

298 Path, 

299 typer.Argument( 

300 help="Path to target repository", 

301 ), 

302 ] = Path("."), 

303) -> None: 

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

305 

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

307 from the template repository as a directory tree. 

308 

309 Examples: 

310 rhiza tree 

311 rhiza tree /path/to/project 

312 """ 

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

314 tree_cmd(target.resolve()) 

315 

316 

317@app.command() 

318def validate( 

319 target: Annotated[ 

320 Path, 

321 typer.Argument( 

322 exists=True, 

323 file_okay=False, 

324 dir_okay=True, 

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

326 ), 

327 ] = Path("."), 

328 path_to_template: Annotated[ 

329 Path | None, 

330 typer.Option( 

331 "--path-to-template", 

332 help=( 

333 "Directory containing template.yml " 

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

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

336 ), 

337 ), 

338 ] = None, 

339) -> None: 

340 r"""Validate Rhiza template configuration. 

341 

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

343 correct and semantically valid. 

344 

345 Performs comprehensive validation: 

346 - Checks if template.yml exists 

347 - Validates YAML syntax 

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

349 - Validates field types and formats 

350 - Ensures repository name follows owner/repo format 

351 - Confirms include paths are not empty 

352 

353 

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

355 

356 Examples: 

357 rhiza validate 

358 rhiza validate /path/to/project 

359 rhiza validate .. 

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

361 rhiza validate --path-to-template . 

362 """ 

363 template_file = None 

364 if path_to_template is not None: 

365 template_file = path_to_template / "template.yml" 

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

367 raise typer.Exit(code=1) 

368 

369 

370@app.command() 

371def migrate( 

372 target: Annotated[ 

373 Path, 

374 typer.Argument( 

375 exists=True, 

376 file_okay=False, 

377 dir_okay=True, 

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

379 ), 

380 ] = Path("."), 

381) -> None: 

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

383 

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

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

386 the following migrations: 

387 

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

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

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

391 

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

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

394 

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

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

397 the migration was successful. 

398 

399 Examples: 

400 rhiza migrate 

401 rhiza migrate /path/to/project 

402 """ 

403 migrate_cmd(target) 

404 

405 

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

407def list_repos( 

408 topic: str = typer.Option( 

409 "rhiza", 

410 "--topic", 

411 "-t", 

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

413 ), 

414) -> None: 

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

416 

417 Queries the GitHub Search API for repositories tagged with the 

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

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

420 

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

422 

423 Examples: 

424 rhiza list 

425 rhiza list --topic rhiza-go 

426 """ 

427 if not list_repos_cmd(topic): 

428 raise typer.Exit(code=1) 

429 

430 

431@app.command() 

432def uninstall( 

433 target: Annotated[ 

434 Path, 

435 typer.Argument( 

436 exists=True, 

437 file_okay=False, 

438 dir_okay=True, 

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

440 ), 

441 ] = Path("."), 

442 force: bool = typer.Option( 

443 False, 

444 "--force", 

445 "-y", 

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

447 ), 

448) -> None: 

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

450 

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

452 previously synced by Rhiza templates. This provides a clean 

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

454 

455 The command will: 

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

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

458 - Delete all listed files that exist 

459 - Remove empty directories left behind 

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

461 

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

463 from your project. 

464 

465 Examples: 

466 rhiza uninstall 

467 rhiza uninstall --force 

468 rhiza uninstall /path/to/project 

469 rhiza uninstall /path/to/project -y 

470 """ 

471 with _exit_on_error(RuntimeError): 

472 uninstall_cmd(target, force) 

473 

474 

475@app.command() 

476def summarise( 

477 target: Annotated[ 

478 Path, 

479 typer.Argument( 

480 exists=True, 

481 file_okay=False, 

482 dir_okay=True, 

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

484 ), 

485 ] = Path("."), 

486 output: Annotated[ 

487 Path | None, 

488 typer.Option( 

489 "--output", 

490 "-o", 

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

492 ), 

493 ] = None, 

494 no_header: Annotated[ 

495 bool, 

496 typer.Option("--no-header", help="Suppress the header section."), 

497 ] = False, 

498 no_footer: Annotated[ 

499 bool, 

500 typer.Option("--no-footer", help="Suppress the footer section."), 

501 ] = False, 

502 no_categories: Annotated[ 

503 bool, 

504 typer.Option("--no-categories", help="Show a flat file list instead of grouping by category."), 

505 ] = False, 

506 output_format: Annotated[ 

507 str, 

508 typer.Option( 

509 "--format", 

510 "-f", 

511 help="Output format: markdown (default), plain, or json.", 

512 ), 

513 ] = "markdown", 

514 title: Annotated[ 

515 str | None, 

516 typer.Option("--title", help="Override the PR description title (markdown / plain formats)."), 

517 ] = None, 

518 compare_ref: Annotated[ 

519 str | None, 

520 typer.Option( 

521 "--compare", 

522 help="Compare against this git ref instead of staged changes (e.g. 'main', 'HEAD~1').", 

523 ), 

524 ] = None, 

525 jinja2_template: Annotated[ 

526 Path | None, 

527 typer.Option( 

528 "--template", 

529 "-t", 

530 exists=True, 

531 file_okay=True, 

532 dir_okay=False, 

533 help="Path to a Jinja2 template file for fully custom output.", 

534 ), 

535 ] = None, 

536) -> None: 

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

538 

539 Analyzes staged git changes and generates a structured PR description 

540 that includes: 

541 

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

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

544 - Template repository information 

545 - Last sync date 

546 

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

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

549 

550 Examples: 

551 rhiza summarise 

552 rhiza summarise --output pr-description.md 

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

554 rhiza summarise --format json 

555 rhiza summarise --no-categories --no-footer 

556 rhiza summarise --compare main 

557 rhiza summarise --template my-template.md.j2 

558 

559 Typical workflow: 

560 rhiza sync 

561 git add . 

562 rhiza summarise --output pr-body.md 

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

564 """ 

565 with _exit_on_error(RuntimeError): 

566 summarise_cmd( 

567 target, 

568 output, 

569 options=SummariseOptions( 

570 include_header=not no_header, 

571 include_footer=not no_footer, 

572 include_categories=not no_categories, 

573 output_format=output_format, 

574 title=title, 

575 compare_ref=compare_ref, 

576 jinja2_template=jinja2_template, 

577 ), 

578 )