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

76 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-02-23 01:10 +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__, console 

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.") 

57 

58 

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

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

61 if verbose: 

62 configure_console(verbose=True) 

63 

64 

65@app.callback() 

66def main( 

67 version: bool = typer.Option( 

68 None, 

69 "--version", 

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

71 callback=version_callback, 

72 is_eager=True, 

73 ), 

74 verbose: bool = VERBOSE_OPTION, 

75) -> None: 

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

77 configure_console(verbose=verbose) 

78 

79 

80@app.command() 

81def bump( 

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

83 language: str | None = typer.Option( 

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

85 ), 

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

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

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

89 branch: str | None = typer.Option( 

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

91 ), 

92 allow_dirty: bool = typer.Option( 

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

94 ), 

95 verbose: bool = VERBOSE_OPTION, 

96) -> None: 

97 """Bump the version of the project. 

98 

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

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

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

102 

103 Args: 

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

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

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

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

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

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

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

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

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

113 verbose: If True, enable verbose debug output. 

114 

115 Example: 

116 Bump to a specific version:: 

117 

118 $ rhiza-tools bump 2.0.0 

119 

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

121 

122 $ rhiza-tools bump patch 

123 

124 Bump a Go project explicitly:: 

125 

126 $ rhiza-tools bump minor --language go 

127 

128 Preview changes without applying them:: 

129 

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

131 

132 Interactive version selection:: 

133 

134 $ rhiza-tools bump 

135 

136 Bump and push to remote:: 

137 

138 $ rhiza-tools bump minor --push 

139 """ 

140 _apply_verbose(verbose) 

141 from rhiza_tools.commands.bump import BumpOptions, Language 

142 

143 # Parse language if provided 

144 lang_enum = None 

145 if language: 

146 try: 

147 lang_enum = Language(language.lower()) 

148 except ValueError: 

149 console.error(f"Invalid language: {language}") 

150 console.error("Supported languages: python, go") 

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

152 

153 options = BumpOptions( 

154 version=version, 

155 dry_run=dry_run, 

156 commit=commit, 

157 push=push, 

158 branch=branch, 

159 allow_dirty=allow_dirty, 

160 language=lang_enum, 

161 ) 

162 bump_command(options) 

163 

164 

165@app.command() 

166def generate_coverage_badge( 

167 coverage_json: Annotated[ 

168 Path, 

169 typer.Option( 

170 "--coverage-json", 

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

172 ), 

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

174 output: Annotated[ 

175 Path, 

176 typer.Option( 

177 help="Path to output badge JSON", 

178 ), 

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

180 verbose: bool = VERBOSE_OPTION, 

181) -> None: 

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

183 

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

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

186 on the coverage percentage. 

187 

188 Args: 

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

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

191 verbose: If True, enable verbose debug output. 

192 

193 Example: 

194 Generate badge with default paths:: 

195 

196 $ rhiza-tools generate-coverage-badge 

197 

198 Generate badge with custom paths:: 

199 

200 $ rhiza-tools generate-coverage-badge \ 

201 --coverage-json tests/coverage.json \ 

202 --output assets/badge.json 

203 """ 

204 _apply_verbose(verbose) 

205 generate_coverage_badge_command(coverage_json_path=coverage_json, output_path=output) 

206 

207 

208@app.command() 

209def release( 

210 bump: str | None = typer.Option(None, "--bump", help="Bump type (MAJOR, MINOR, PATCH) before release."), 

211 with_bump: bool = typer.Option( 

212 False, 

213 "--with-bump", 

214 help="Interactively select bump type before release (works with --dry-run).", 

215 ), 

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

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

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

219 language: str | None = typer.Option( 

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

221 ), 

222 verbose: bool = VERBOSE_OPTION, 

223) -> None: 

224 """Push a release tag to remote to trigger the release workflow. 

225 

226 This command validates the repository state and pushes the git tag for the 

227 current version to the remote repository, which triggers the automated release 

228 workflow. Supports Python projects (pyproject.toml) and Go projects 

229 (go.mod + VERSION file). The project language is auto-detected when not 

230 explicitly specified. 

231 

232 Args: 

233 bump: Bump type (MAJOR, MINOR, PATCH) to apply before release. 

234 with_bump: If True, interactively select bump type before release. 

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

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

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

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

239 verbose: If True, enable verbose debug output. 

240 

241 Example: 

242 Push a release tag (with prompts):: 

243 

244 $ rhiza-tools release 

245 

246 Preview what would happen:: 

247 

248 $ rhiza-tools release --dry-run 

249 

250 Non-interactive mode (for CI/CD):: 

251 

252 $ rhiza-tools release --non-interactive 

253 

254 Bump version and release:: 

255 

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

257 

258 Interactive bump with dry-run preview:: 

259 

260 $ rhiza-tools release --with-bump --push --dry-run 

261 

262 Release a Go project:: 

263 

264 $ rhiza-tools release --language go 

265 """ 

266 _apply_verbose(verbose) 

267 from rhiza_tools.commands.bump import Language 

268 

269 lang_enum = None 

270 if language: 

271 try: 

272 lang_enum = Language(language.lower()) 

273 except ValueError: 

274 console.error(f"Invalid language: {language}") 

275 console.error("Supported languages: python, go") 

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

277 

278 release_command(bump, push, dry_run, non_interactive, with_bump, lang_enum) 

279 

280 

281@app.command() 

282def rollback( 

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

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

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

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

287 verbose: bool = VERBOSE_OPTION, 

288) -> None: 

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

290 

291 This command safely reverses release and bump operations by deleting 

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

293 reverting the version bump commit. 

294 

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

296 even when changes have already been pushed to remote. 

297 

298 Args: 

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

300 menu shows recent tags to choose from. 

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

302 with the tag. 

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

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

305 verbose: If True, enable verbose debug output. 

306 

307 Example: 

308 Rollback the most recent release interactively:: 

309 

310 $ rhiza-tools rollback 

311 

312 Preview rollback of a specific tag:: 

313 

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

315 

316 Fully rollback including the bump commit:: 

317 

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

319 

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

321 

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

323 """ 

324 _apply_verbose(verbose) 

325 from rhiza_tools.commands.rollback import RollbackOptions 

326 

327 options = RollbackOptions( 

328 tag=tag, 

329 revert_bump=revert_bump, 

330 dry_run=dry_run, 

331 non_interactive=non_interactive, 

332 ) 

333 rollback_command(options) 

334 

335 

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

337def update_readme( 

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

339 verbose: bool = VERBOSE_OPTION, 

340) -> None: 

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

342 

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

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

345 

346 Args: 

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

348 actually modifying README.md. 

349 verbose: If True, enable verbose debug output. 

350 

351 Example: 

352 Update README with make help output:: 

353 

354 $ rhiza-tools update-readme 

355 

356 Preview changes without modifying README:: 

357 

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

359 """ 

360 _apply_verbose(verbose) 

361 update_readme_command(dry_run) 

362 

363 

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

365def version_matrix( 

366 pyproject: Annotated[ 

367 Path, 

368 typer.Option( 

369 "--pyproject", 

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

371 ), 

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

373 candidates: Annotated[ 

374 str | None, 

375 typer.Option( 

376 "--candidates", 

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

378 ), 

379 ] = None, 

380 verbose: bool = VERBOSE_OPTION, 

381) -> None: 

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

383 

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

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

386 used in GitHub Actions to compute the test matrix. 

387 

388 Args: 

389 pyproject: Path to the pyproject.toml file. 

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

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

392 verbose: If True, enable verbose debug output. 

393 

394 Example: 

395 Get supported versions with defaults:: 

396 

397 $ rhiza-tools version-matrix 

398 ["3.11", "3.12"] 

399 

400 Use custom pyproject.toml path:: 

401 

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

403 

404 Use custom candidates:: 

405 

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

407 """ 

408 _apply_verbose(verbose) 

409 candidates_list = None 

410 if candidates: 

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

412 

413 version_matrix_command(pyproject_path=pyproject, candidates=candidates_list) 

414 

415 

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

417def analyze_benchmarks( 

418 benchmarks_json: Annotated[ 

419 Path, 

420 typer.Option( 

421 "--benchmarks-json", 

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

423 ), 

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

425 output_html: Annotated[ 

426 Path, 

427 typer.Option( 

428 "--output-html", 

429 help="Path to save HTML visualization", 

430 ), 

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

432 verbose: bool = VERBOSE_OPTION, 

433) -> None: 

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

435 

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

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

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

439 

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

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

442 

443 Args: 

444 benchmarks_json: Path to the benchmarks.json file. 

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

446 verbose: If True, enable verbose debug output. 

447 

448 Example: 

449 Analyze benchmarks with default paths:: 

450 

451 $ rhiza-tools analyze-benchmarks 

452 

453 Use custom paths:: 

454 

455 $ rhiza-tools analyze-benchmarks \ 

456 --benchmarks-json tests/benchmarks.json \ 

457 --output-html reports/benchmarks.html 

458 """ 

459 _apply_verbose(verbose) 

460 analyze_benchmarks_command(benchmarks_json=benchmarks_json, output_html=output_html)