Coverage for src / rhiza / cli.py: 100%
39 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-07-16 12:51 +0000
« prev ^ index » next coverage.py v7.14.0, created at 2026-07-16 12:51 +0000
1"""Rhiza command-line interface (CLI).
3This module defines the Typer application entry points exposed by Rhiza.
4Commands are thin wrappers around implementations in `rhiza.commands.*`.
5"""
7import subprocess # nosec B404
8from collections.abc import Iterator
9from contextlib import contextmanager
10from pathlib import Path
11from typing import Annotated
13import typer
15from rhiza import __version__
16from rhiza.commands.summarise import SummariseOptions
17from rhiza.commands.summarise import summarise as summarise_cmd
18from rhiza.commands.sync import sync as sync_cmd
21@contextmanager
22def _exit_on_error(*exc_types: type[BaseException]) -> Iterator[None]:
23 """Context manager that catches specified exceptions and exits with code 1.
25 Args:
26 *exc_types: Exception types to catch. Defaults to catching Exception
27 if none are provided.
28 """
29 _types: tuple[type[BaseException], ...] = exc_types if exc_types else (Exception,)
30 try:
31 yield
32 except _types:
33 raise typer.Exit(code=1) from None
36app = typer.Typer(
37 help=(
38 """
39 Rhiza - Manage reusable configuration templates for Python projects
41 https://jebel-quant.github.io/rhiza-cli/
42 """
43 ),
44 add_completion=True,
45)
48def version_callback(value: bool) -> None:
49 """Print version information and exit.
51 Args:
52 value: Whether the --version flag was provided.
54 Raises:
55 typer.Exit: Always exits after printing version.
56 """
57 if value:
58 typer.echo(f"rhiza version {__version__}")
59 raise typer.Exit()
62@app.callback()
63def main(
64 version: bool = typer.Option(
65 False,
66 "--version",
67 "-v",
68 help="Show version and exit",
69 callback=version_callback,
70 is_eager=True,
71 ),
72) -> None:
73 """Rhiza CLI main callback.
75 This callback is executed before any command. It handles global options
76 like --version.
78 Args:
79 version: Version flag (handled by callback).
80 """
83@app.command()
84def sync(
85 target: Annotated[
86 Path,
87 typer.Argument(
88 exists=True,
89 file_okay=False,
90 dir_okay=True,
91 help="Target git repository (defaults to current directory)",
92 ),
93 ] = Path("."),
94 branch: str = typer.Option("main", "--branch", "-b", help="Rhiza branch to use"),
95 target_branch: str = typer.Option(
96 None,
97 "--target-branch",
98 "--checkout-branch",
99 help="Create and checkout a new branch in the target repository for changes",
100 ),
101 strategy: str = typer.Option(
102 "merge",
103 "--strategy",
104 "-s",
105 help="Sync strategy: 'merge' (3-way merge preserving local changes) or 'diff' (dry-run showing changes)",
106 ),
107 path_to_template: Annotated[
108 Path | None,
109 typer.Option(
110 "--path-to-template",
111 help=(
112 "Directory containing template.yml and where template.lock will be written "
113 "(defaults to <TARGET>/.rhiza). "
114 "Use '.' to keep both files in the project root."
115 ),
116 ),
117 ] = None,
118) -> None:
119 r"""Sync templates using diff/merge, preserving local customisations.
121 This is the primary command for keeping your project up to date with
122 the template repository.
124 On **first sync** (no lock file) the command copies all template files and
125 records the current template HEAD in `.rhiza/template.lock`. On
126 **subsequent syncs** it computes the diff between the last-synced commit
127 and the current HEAD then applies it via ``git apply -3`` so local edits
128 are preserved.
130 The command tracks the last-synced template commit in
131 `.rhiza/template.lock`. On subsequent syncs it computes the diff
132 between two snapshots of the template:
134 \b
135 - base: the template at the last-synced commit
136 - upstream: the template at the current branch HEAD
137 - local: the file in your project (possibly customised)
139 Files that changed only upstream are updated automatically.
140 Files that changed only locally are left untouched.
141 Files that changed in both places are merged; conflicts are marked
142 with standard git conflict markers for manual resolution.
144 Strategies:
145 \b
146 - merge: 3-way merge preserving local changes (default)
147 - diff: dry-run showing what would change
149 Examples:
150 rhiza sync
151 rhiza sync --strategy diff
152 rhiza sync --branch develop
153 rhiza sync --target-branch feature/update-templates
154 rhiza sync --path-to-template /custom/rhiza
155 rhiza sync --path-to-template .
156 """
157 if strategy not in ("merge", "diff"):
158 typer.echo(f"Unknown strategy: {strategy}. Must be 'merge' or 'diff'.")
159 raise typer.Exit(code=1)
160 template_file = lock_file = None
161 if path_to_template is not None:
162 template_file = path_to_template / "template.yml"
163 lock_file = path_to_template / "template.lock"
164 with _exit_on_error(subprocess.CalledProcessError, RuntimeError, ValueError):
165 sync_cmd(target, branch, target_branch, strategy, template_file=template_file, lock_file=lock_file)
168@app.command()
169def summarise(
170 target: Annotated[
171 Path,
172 typer.Argument(
173 exists=True,
174 file_okay=False,
175 dir_okay=True,
176 help="Target git repository (defaults to current directory)",
177 ),
178 ] = Path("."),
179 output: Annotated[
180 Path | None,
181 typer.Option(
182 "--output",
183 "-o",
184 help="Output file path (defaults to stdout)",
185 ),
186 ] = None,
187 no_header: Annotated[
188 bool,
189 typer.Option("--no-header", help="Suppress the header section."),
190 ] = False,
191 no_footer: Annotated[
192 bool,
193 typer.Option("--no-footer", help="Suppress the footer section."),
194 ] = False,
195 no_categories: Annotated[
196 bool,
197 typer.Option("--no-categories", help="Show a flat file list instead of grouping by category."),
198 ] = False,
199 output_format: Annotated[
200 str,
201 typer.Option(
202 "--format",
203 "-f",
204 help="Output format: markdown (default), plain, or json.",
205 ),
206 ] = "markdown",
207 title: Annotated[
208 str | None,
209 typer.Option("--title", help="Override the PR description title (markdown / plain formats)."),
210 ] = None,
211 compare_ref: Annotated[
212 str | None,
213 typer.Option(
214 "--compare",
215 help="Compare against this git ref instead of staged changes (e.g. 'main', 'HEAD~1').",
216 ),
217 ] = None,
218 jinja2_template: Annotated[
219 Path | None,
220 typer.Option(
221 "--template",
222 "-t",
223 exists=True,
224 file_okay=True,
225 dir_okay=False,
226 help="Path to a Jinja2 template file for fully custom output.",
227 ),
228 ] = None,
229) -> None:
230 r"""Generate a summary of staged changes for PR descriptions.
232 Analyzes staged git changes and generates a structured PR description
233 that includes:
235 - Summary statistics (files added/modified/deleted)
236 - Changes categorized by type (workflows, configs, docs, tests, etc.)
237 - Template repository information
238 - Last sync date
240 This is useful when creating pull requests after running `rhiza sync`
241 to provide reviewers with a clear overview of what changed.
243 Examples:
244 rhiza summarise
245 rhiza summarise --output pr-description.md
246 rhiza summarise /path/to/project -o description.md
247 rhiza summarise --format json
248 rhiza summarise --no-categories --no-footer
249 rhiza summarise --compare main
250 rhiza summarise --template my-template.md.j2
252 Typical workflow:
253 rhiza sync
254 git add .
255 rhiza summarise --output pr-body.md
256 gh pr create --title "chore: Sync with rhiza" --body-file pr-body.md
257 """
258 with _exit_on_error(RuntimeError):
259 summarise_cmd(
260 target,
261 output,
262 options=SummariseOptions(
263 include_header=not no_header,
264 include_footer=not no_footer,
265 include_categories=not no_categories,
266 output_format=output_format,
267 title=title,
268 compare_ref=compare_ref,
269 jinja2_template=jinja2_template,
270 ),
271 )