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
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-30 13:37 +0000
1"""CLI commands for Rhiza Tools.
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.
7The CLI can be used either as a standalone tool (`rhiza-tools`) or as a
8subcommand of the rhiza CLI (`rhiza tools`).
10Example:
11 Bump version to a specific version::
13 $ rhiza-tools bump 1.2.3
14 $ rhiza tools bump 1.2.3
16 Bump version interactively::
18 $ rhiza-tools bump
20 Generate a coverage badge::
22 $ rhiza-tools generate-coverage-badge --coverage-json _tests/coverage.json
24 Update README with make help output::
26 $ rhiza-tools update-readme
27"""
29from pathlib import Path
30from typing import Annotated
32import typer
34from rhiza_tools import __version__
35from rhiza_tools.console import configure as configure_console
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
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()
53app = typer.Typer(help="Rhiza Tools - Extra utilities for Rhiza.")
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)
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)
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)
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.
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.
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.
123 Example:
124 Bump to a specific version::
126 $ rhiza-tools bump 2.0.0
128 Bump patch version (1.2.3 -> 1.2.4)::
130 $ rhiza-tools bump patch
132 Bump a Go project explicitly::
134 $ rhiza-tools bump minor --language go
136 Preview changes without applying them::
138 $ rhiza-tools bump minor --dry-run
140 Interactive version selection::
142 $ rhiza-tools bump
144 Bump and push to remote::
146 $ rhiza-tools bump minor --push
148 Use a custom config file::
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
155 lang_enum = parse_language_option(language)
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)
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.
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.
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.
198 Example:
199 Generate badge with default paths::
201 $ rhiza-tools generate-coverage-badge
203 Generate badge with custom paths::
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)
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.
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.
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.
252 Example:
253 Bump (interactive) and release::
255 $ rhiza-tools release
257 Preview what would happen::
259 $ rhiza-tools release --dry-run
261 Non-interactive patch release (for CI/CD)::
263 $ rhiza-tools release --non-interactive
265 Explicit bump and release::
267 $ rhiza-tools release --bump MINOR --push
269 Explicit bump and release with custom config::
271 $ rhiza-tools release --bump MINOR --push --config /path/to/.cfg.toml
273 Release a Go project::
275 $ rhiza-tools release --language go
276 """
277 _apply_verbose(verbose)
278 from rhiza_tools.commands.bump import parse_language_option
280 lang_enum = parse_language_option(language)
282 release_command(bump, push, dry_run, non_interactive, lang_enum, config, allow_older)
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.
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.
299 It uses ``git revert`` rather than ``git reset``, making it safe
300 even when changes have already been pushed to remote.
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.
311 Example:
312 Rollback the most recent release interactively::
314 $ rhiza-tools rollback
316 Preview rollback of a specific tag::
318 $ rhiza-tools rollback v1.2.3 --dry-run
320 Fully rollback including the bump commit::
322 $ rhiza-tools rollback v1.2.3 --revert-bump
324 Non-interactive rollback (for CI/CD)::
326 $ rhiza-tools rollback v1.2.3 --revert-bump -y
327 """
328 _apply_verbose(verbose)
329 from rhiza_tools.commands.rollback import RollbackOptions
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)
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`.
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.
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.
355 Example:
356 Update README with make help output::
358 $ rhiza-tools update-readme
360 Preview changes without modifying README::
362 $ rhiza-tools update-readme --dry-run
363 """
364 _apply_verbose(verbose)
365 update_readme_command(dry_run)
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.
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.
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.
398 Example:
399 Get supported versions with defaults::
401 $ rhiza-tools version-matrix
402 ["3.11", "3.12"]
404 Use custom pyproject.toml path::
406 $ rhiza-tools version-matrix --pyproject /path/to/pyproject.toml
408 Use custom candidates::
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(",")]
417 version_matrix_command(pyproject_path=pyproject, candidates=candidates_list)
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.
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.
451 Note: This command requires pandas and plotly. Install with:
452 uv pip install -e '.[dev]' or pip install 'rhiza-tools[dev]'
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.
460 Example:
461 Analyze benchmarks with default paths::
463 $ rhiza-tools analyze-benchmarks
465 Use custom paths::
467 $ rhiza-tools analyze-benchmarks \
468 --benchmarks-json tests/benchmarks.json \
469 --output-html reports/benchmarks.html
471 Open the chart in a browser after saving::
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)