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

63 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-30 13:37 +0000

1"""CLI commands for Rhiza Tools. 

2 

3This module defines the main Typer application and all command-line interface 

4commands for rhiza-tools. It provides commands for version bumping, coverage 

5badge generation, release management, and README updates. 

6 

7The CLI can be used either as a standalone tool (`rhiza-tools`) or as a 

8subcommand of the rhiza CLI (`rhiza tools`). 

9 

10Example: 

11 Bump version to a specific version:: 

12 

13 $ rhiza-tools bump 1.2.3 

14 $ rhiza tools bump 1.2.3 

15 

16 Bump version interactively:: 

17 

18 $ rhiza-tools bump 

19 

20 Generate a coverage badge:: 

21 

22 $ rhiza-tools generate-coverage-badge --coverage-json _tests/coverage.json 

23 

24 Update README with make help output:: 

25 

26 $ rhiza-tools update-readme 

27""" 

28 

29from pathlib import Path 

30from typing import Annotated 

31 

32import typer 

33 

34from rhiza_tools import __version__ 

35from rhiza_tools.console import configure as configure_console 

36 

37from .commands.analyze_benchmarks import analyze_benchmarks_command 

38from .commands.bump import bump_command 

39from .commands.generate_badge import generate_coverage_badge_command 

40from .commands.release import release_command 

41from .commands.rollback import rollback_command 

42from .commands.update_readme import update_readme_command 

43from .commands.version_matrix import version_matrix_command 

44 

45 

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

47 """Display the version and exit.""" 

48 if value: 

49 typer.echo(f"rhiza-tools version {__version__}") 

50 raise typer.Exit() 

51 

52 

53app = typer.Typer(help="Rhiza Tools - Extra utilities for Rhiza.") 

54 

55# Shared option so --verbose / -v works both before and after the subcommand. 

56VERBOSE_OPTION = typer.Option(False, "--verbose", "-v", help="Show verbose debug output.") 

57CONFIG_OPTION = typer.Option( 

58 None, 

59 "--config", 

60 "-c", 

61 help="Path to the .cfg.toml bumpversion config file. Defaults to .rhiza/.cfg.toml.", 

62) 

63 

64 

65def _apply_verbose(verbose: bool) -> None: 

66 """Enable verbose output if the flag was passed on the subcommand.""" 

67 if verbose: 

68 configure_console(verbose=True) 

69 

70 

71@app.callback() 

72def main( 

73 version: bool = typer.Option( # noqa: ARG001 — eager option; value is consumed by version_callback, not the body 

74 None, 

75 "--version", 

76 help="Show the version and exit.", 

77 callback=version_callback, 

78 is_eager=True, 

79 ), 

80 verbose: bool = VERBOSE_OPTION, 

81) -> None: 

82 """Rhiza Tools - Extra utilities for Rhiza.""" 

83 configure_console(verbose=verbose) 

84 

85 

86@app.command() 

87def bump( 

88 version: str | None = typer.Argument(None, help="The version to bump to (e.g., 1.0.1, major, minor, patch, etc)"), 

89 language: str | None = typer.Option( 

90 None, "--language", "-l", help="Programming language (python or go). Auto-detected if not specified." 

91 ), 

92 dry_run: bool = typer.Option(False, "--dry-run", help="Print what would happen without doing it."), 

93 commit: bool = typer.Option(False, "--commit", help="Commit the changes to git."), 

94 push: bool = typer.Option(False, "--push", help="Push changes to remote after commit (implies --commit)."), 

95 branch: str | None = typer.Option( 

96 None, "--branch", help="Branch to perform the bump on (default: current branch)." 

97 ), 

98 allow_dirty: bool = typer.Option( 

99 False, "--allow-dirty", help="Allow bumping even if the working directory is dirty." 

100 ), 

101 config: Path | None = CONFIG_OPTION, 

102 verbose: bool = VERBOSE_OPTION, 

103) -> None: 

104 """Bump the version of the project. 

105 

106 This command updates the version for Python (pyproject.toml) or Go (VERSION file) 

107 projects using semantic versioning. You can provide an explicit version number, 

108 a bump type (patch, minor, major), or leave it blank for an interactive prompt. 

109 

110 Args: 

111 version: The version to bump to. Can be an explicit version (e.g., "1.2.3"), 

112 a bump type ("patch", "minor", "major"), a prerelease type 

113 ("alpha", "beta", "rc", "dev"), or None for interactive selection. 

114 language: Programming language (python or go). Auto-detected if not specified. 

115 dry_run: If True, show what would change without actually changing anything. 

116 commit: If True, automatically commit the version change to git. 

117 push: If True, push changes to remote after commit (implies --commit). 

118 branch: Branch to perform the bump on (default: current branch). 

119 allow_dirty: If True, allow bumping even with uncommitted changes. 

120 config: Path to the .cfg.toml bumpversion config file. Defaults to .rhiza/.cfg.toml. 

121 verbose: If True, enable verbose debug output. 

122 

123 Example: 

124 Bump to a specific version:: 

125 

126 $ rhiza-tools bump 2.0.0 

127 

128 Bump patch version (1.2.3 -> 1.2.4):: 

129 

130 $ rhiza-tools bump patch 

131 

132 Bump a Go project explicitly:: 

133 

134 $ rhiza-tools bump minor --language go 

135 

136 Preview changes without applying them:: 

137 

138 $ rhiza-tools bump minor --dry-run 

139 

140 Interactive version selection:: 

141 

142 $ rhiza-tools bump 

143 

144 Bump and push to remote:: 

145 

146 $ rhiza-tools bump minor --push 

147 

148 Use a custom config file:: 

149 

150 $ rhiza-tools bump patch --config /path/to/my.cfg.toml 

151 """ 

152 _apply_verbose(verbose) 

153 from rhiza_tools.commands.bump import BumpOptions, parse_language_option 

154 

155 lang_enum = parse_language_option(language) 

156 

157 options = BumpOptions( 

158 version=version, 

159 dry_run=dry_run, 

160 commit=commit, 

161 push=push, 

162 branch=branch, 

163 allow_dirty=allow_dirty, 

164 language=lang_enum, 

165 config=config, 

166 ) 

167 bump_command(options) 

168 

169 

170@app.command() 

171def generate_coverage_badge( 

172 coverage_json: Annotated[ 

173 Path, 

174 typer.Option( 

175 "--coverage-json", 

176 help="Path to coverage.json file", 

177 ), 

178 ] = Path("_tests/coverage.json"), 

179 output: Annotated[ 

180 Path, 

181 typer.Option( 

182 help="Path to output badge JSON", 

183 ), 

184 ] = Path("_book/tests/coverage-badge.json"), 

185 verbose: bool = VERBOSE_OPTION, 

186) -> None: 

187 """Generate a coverage badge for the project. 

188 

189 Reads a coverage report JSON file and creates a shields.io endpoint JSON file 

190 for displaying a coverage badge. The badge color automatically adjusts based 

191 on the coverage percentage. 

192 

193 Args: 

194 coverage_json: Path to the coverage.json file generated by pytest-cov. 

195 output: Path where the badge JSON file should be written. 

196 verbose: If True, enable verbose debug output. 

197 

198 Example: 

199 Generate badge with default paths:: 

200 

201 $ rhiza-tools generate-coverage-badge 

202 

203 Generate badge with custom paths:: 

204 

205 $ rhiza-tools generate-coverage-badge \ 

206 --coverage-json tests/coverage.json \ 

207 --output assets/badge.json 

208 """ 

209 _apply_verbose(verbose) 

210 generate_coverage_badge_command(coverage_json_path=coverage_json, output_path=output) 

211 

212 

213@app.command() 

214def release( 

215 bump: str | None = typer.Option( 

216 None, "--bump", help="Bump type (MAJOR, MINOR, PATCH). Selected interactively when omitted." 

217 ), 

218 push: bool = typer.Option(False, "--push", help="Push changes to remote (default: prompt in interactive mode)."), 

219 dry_run: bool = typer.Option(False, "--dry-run", help="Print what would happen without doing it."), 

220 non_interactive: bool = typer.Option(False, "--non-interactive", "-y", help="Skip all confirmation prompts."), 

221 language: str | None = typer.Option( 

222 None, "--language", "-l", help="Programming language (python or go). Auto-detected if not specified." 

223 ), 

224 allow_older: bool = typer.Option( 

225 False, 

226 "--allow-older", 

227 help="Allow releasing a version not newer than the latest remote release (maintenance/back-branch).", 

228 ), 

229 config: Path | None = CONFIG_OPTION, 

230 verbose: bool = VERBOSE_OPTION, 

231) -> None: 

232 """Bump the version and push a release tag to remote to trigger the release workflow. 

233 

234 A release always bumps the version before tagging: the bump type is taken 

235 from ``--bump`` when given, selected interactively otherwise, or defaults to 

236 patch in non-interactive mode. The command then validates the repository 

237 state and pushes the git tag, which triggers the automated release workflow. 

238 Supports Python projects (pyproject.toml) and Go projects (go.mod + VERSION 

239 file). The project language is auto-detected when not explicitly specified. 

240 

241 Args: 

242 bump: Bump type (MAJOR, MINOR, PATCH) to apply. Selected interactively when omitted. 

243 push: If True, push changes without prompting (implies non-interactive for push). 

244 dry_run: If True, show what would happen without actually pushing the tag. 

245 non_interactive: If True, skip all confirmation prompts and default the bump to 

246 patch when no --bump type is given (useful for CI/CD). 

247 language: Programming language (python or go). Auto-detected if not specified. 

248 allow_older: If True, allow releasing a version not newer than the latest remote release. 

249 config: Path to the .cfg.toml bumpversion config file. Passed through to the bump. 

250 verbose: If True, enable verbose debug output. 

251 

252 Example: 

253 Bump (interactive) and release:: 

254 

255 $ rhiza-tools release 

256 

257 Preview what would happen:: 

258 

259 $ rhiza-tools release --dry-run 

260 

261 Non-interactive patch release (for CI/CD):: 

262 

263 $ rhiza-tools release --non-interactive 

264 

265 Explicit bump and release:: 

266 

267 $ rhiza-tools release --bump MINOR --push 

268 

269 Explicit bump and release with custom config:: 

270 

271 $ rhiza-tools release --bump MINOR --push --config /path/to/.cfg.toml 

272 

273 Release a Go project:: 

274 

275 $ rhiza-tools release --language go 

276 """ 

277 _apply_verbose(verbose) 

278 from rhiza_tools.commands.bump import parse_language_option 

279 

280 lang_enum = parse_language_option(language) 

281 

282 release_command(bump, push, dry_run, non_interactive, lang_enum, config, allow_older) 

283 

284 

285@app.command() 

286def rollback( 

287 tag: str | None = typer.Argument(None, help="Tag to rollback (e.g., v1.2.3). Interactive if omitted."), 

288 revert_bump: bool = typer.Option(False, "--revert-bump", help="Also revert the version bump commit."), 

289 dry_run: bool = typer.Option(False, "--dry-run", help="Print what would happen without doing it."), 

290 non_interactive: bool = typer.Option(False, "--non-interactive", "-y", help="Skip all confirmation prompts."), 

291 verbose: bool = VERBOSE_OPTION, 

292) -> None: 

293 """Rollback a release and/or version bump. 

294 

295 This command safely reverses release and bump operations by deleting 

296 the release tag from local and remote repositories, and optionally 

297 reverting the version bump commit. 

298 

299 It uses ``git revert`` rather than ``git reset``, making it safe 

300 even when changes have already been pushed to remote. 

301 

302 Args: 

303 tag: The tag to rollback (e.g., "v1.2.3"). If omitted, an interactive 

304 menu shows recent tags to choose from. 

305 revert_bump: If True, also revert the version bump commit associated 

306 with the tag. 

307 dry_run: If True, show what would happen without actually making changes. 

308 non_interactive: If True, skip all confirmation prompts (useful for CI/CD). 

309 verbose: If True, enable verbose debug output. 

310 

311 Example: 

312 Rollback the most recent release interactively:: 

313 

314 $ rhiza-tools rollback 

315 

316 Preview rollback of a specific tag:: 

317 

318 $ rhiza-tools rollback v1.2.3 --dry-run 

319 

320 Fully rollback including the bump commit:: 

321 

322 $ rhiza-tools rollback v1.2.3 --revert-bump 

323 

324 Non-interactive rollback (for CI/CD):: 

325 

326 $ rhiza-tools rollback v1.2.3 --revert-bump -y 

327 """ 

328 _apply_verbose(verbose) 

329 from rhiza_tools.commands.rollback import RollbackOptions 

330 

331 options = RollbackOptions( 

332 tag=tag, 

333 revert_bump=revert_bump, 

334 dry_run=dry_run, 

335 non_interactive=non_interactive, 

336 ) 

337 rollback_command(options) 

338 

339 

340@app.command(name="update-readme") 

341def update_readme( 

342 dry_run: bool = typer.Option(False, "--dry-run", help="Print what would happen without doing it."), 

343 verbose: bool = VERBOSE_OPTION, 

344) -> None: 

345 """Update README.md with the current output from `make help`. 

346 

347 This command runs `make help` and updates the README.md file with the current 

348 help output, keeping the documentation in sync with available Make targets. 

349 

350 Args: 

351 dry_run: If True, show the help output that would be inserted without 

352 actually modifying README.md. 

353 verbose: If True, enable verbose debug output. 

354 

355 Example: 

356 Update README with make help output:: 

357 

358 $ rhiza-tools update-readme 

359 

360 Preview changes without modifying README:: 

361 

362 $ rhiza-tools update-readme --dry-run 

363 """ 

364 _apply_verbose(verbose) 

365 update_readme_command(dry_run) 

366 

367 

368@app.command(name="version-matrix") 

369def version_matrix( 

370 pyproject: Annotated[ 

371 Path, 

372 typer.Option( 

373 "--pyproject", 

374 help="Path to pyproject.toml file", 

375 ), 

376 ] = Path("pyproject.toml"), 

377 candidates: Annotated[ 

378 str | None, 

379 typer.Option( 

380 "--candidates", 

381 help="Comma-separated list of candidate Python versions (e.g., '3.11,3.12,3.13')", 

382 ), 

383 ] = None, 

384 verbose: bool = VERBOSE_OPTION, 

385) -> None: 

386 """Emit supported Python versions from pyproject.toml as JSON. 

387 

388 This command reads the requires-python field from pyproject.toml and outputs 

389 a JSON array of Python versions that satisfy the constraint. This is primarily 

390 used in GitHub Actions to compute the test matrix. 

391 

392 Args: 

393 pyproject: Path to the pyproject.toml file. 

394 candidates: Comma-separated list of candidate Python versions to evaluate. 

395 Defaults to "3.11,3.12,3.13,3.14". 

396 verbose: If True, enable verbose debug output. 

397 

398 Example: 

399 Get supported versions with defaults:: 

400 

401 $ rhiza-tools version-matrix 

402 ["3.11", "3.12"] 

403 

404 Use custom pyproject.toml path:: 

405 

406 $ rhiza-tools version-matrix --pyproject /path/to/pyproject.toml 

407 

408 Use custom candidates:: 

409 

410 $ rhiza-tools version-matrix --candidates "3.10,3.11,3.12" 

411 """ 

412 _apply_verbose(verbose) 

413 candidates_list = None 

414 if candidates: 

415 candidates_list = [v.strip() for v in candidates.split(",")] 

416 

417 version_matrix_command(pyproject_path=pyproject, candidates=candidates_list) 

418 

419 

420@app.command(name="analyze-benchmarks") 

421def analyze_benchmarks( 

422 benchmarks_json: Annotated[ 

423 Path, 

424 typer.Option( 

425 "--benchmarks-json", 

426 help="Path to benchmarks.json file", 

427 ), 

428 ] = Path("_benchmarks/benchmarks.json"), 

429 output_html: Annotated[ 

430 Path, 

431 typer.Option( 

432 "--output-html", 

433 help="Path to save HTML visualization", 

434 ), 

435 ] = Path("_benchmarks/benchmarks.html"), 

436 show: Annotated[ 

437 bool, 

438 typer.Option( 

439 "--show/--no-show", 

440 help="Open the interactive chart in a browser after saving (default: no-show)", 

441 ), 

442 ] = False, 

443 verbose: bool = VERBOSE_OPTION, 

444) -> None: 

445 """Analyze pytest-benchmark results and visualize them. 

446 

447 This command reads a benchmarks.json file produced by pytest-benchmark, 

448 prints a table with benchmark name, mean milliseconds, and operations per 

449 second, and generates an interactive Plotly bar chart of mean runtimes. 

450 

451 Note: This command requires pandas and plotly. Install with: 

452 uv pip install -e '.[dev]' or pip install 'rhiza-tools[dev]' 

453 

454 Args: 

455 benchmarks_json: Path to the benchmarks.json file. 

456 output_html: Path where the HTML visualization should be saved. 

457 show: If True, open the interactive chart in a browser after saving. 

458 verbose: If True, enable verbose debug output. 

459 

460 Example: 

461 Analyze benchmarks with default paths:: 

462 

463 $ rhiza-tools analyze-benchmarks 

464 

465 Use custom paths:: 

466 

467 $ rhiza-tools analyze-benchmarks \ 

468 --benchmarks-json tests/benchmarks.json \ 

469 --output-html reports/benchmarks.html 

470 

471 Open the chart in a browser after saving:: 

472 

473 $ rhiza-tools analyze-benchmarks --show 

474 """ 

475 _apply_verbose(verbose) 

476 analyze_benchmarks_command(benchmarks_json=benchmarks_json, output_html=output_html, show=show)