Coverage for src / rhiza / cli.py: 100%
79 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-02 07:04 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-02 07:04 +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
14import yaml
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 summarise as summarise_cmd
23from rhiza.commands.sync import sync as sync_cmd
24from rhiza.commands.tree import tree as tree_cmd
25from rhiza.commands.uninstall import uninstall as uninstall_cmd
28@contextmanager
29def _exit_on_error(*exc_types: type[BaseException]) -> Iterator[None]:
30 """Context manager that catches specified exceptions and exits with code 1.
32 Args:
33 *exc_types: Exception types to catch. Defaults to catching Exception
34 if none are provided.
35 """
36 _types: tuple[type[BaseException], ...] = exc_types if exc_types else (Exception,)
37 try:
38 yield
39 except _types:
40 raise typer.Exit(code=1) from None
43app = typer.Typer(
44 help=(
45 """
46 Rhiza - Manage reusable configuration templates for Python projects
48 https://jebel-quant.github.io/rhiza-cli/
49 """
50 ),
51 add_completion=True,
52)
55def version_callback(value: bool) -> None:
56 """Print version information and exit.
58 Args:
59 value: Whether the --version flag was provided.
61 Raises:
62 typer.Exit: Always exits after printing version.
63 """
64 if value:
65 typer.echo(f"rhiza version {__version__}")
66 raise typer.Exit()
69@app.callback()
70def main(
71 version: bool = typer.Option(
72 False,
73 "--version",
74 "-v",
75 help="Show version and exit",
76 callback=version_callback,
77 is_eager=True,
78 ),
79) -> None:
80 """Rhiza CLI main callback.
82 This callback is executed before any command. It handles global options
83 like --version.
85 Args:
86 version: Version flag (handled by callback).
87 """
90@app.command()
91def init(
92 target: Annotated[
93 Path,
94 typer.Argument(
95 exists=True,
96 file_okay=False,
97 dir_okay=True,
98 help="Target directory (defaults to current directory)",
99 ),
100 ] = Path("."),
101 project_name: str = typer.Option(
102 None,
103 "--project-name",
104 help="Custom project name (defaults to directory name)",
105 ),
106 package_name: str = typer.Option(
107 None,
108 "--package-name",
109 help="Custom package name (defaults to normalized project name)",
110 ),
111 with_dev_dependencies: bool = typer.Option(
112 False,
113 "--with-dev-dependencies",
114 help="Include development dependencies in pyproject.toml",
115 ),
116 git_host: str = typer.Option(
117 None,
118 "--git-host",
119 help="Target Git hosting platform (github or gitlab). Determines which CI/CD files to include. "
120 "If not provided, will prompt interactively.",
121 ),
122 language: str = typer.Option(
123 "python",
124 "--language",
125 help="Programming language for the project (python, go, etc.). Defaults to 'python'.",
126 ),
127 template_repository: str = typer.Option(
128 None,
129 "--template-repository",
130 help=(
131 "Custom template repository (format: owner/repo). "
132 "Defaults to 'jebel-quant/rhiza' for Python or 'jebel-quant/rhiza-go' for Go."
133 ),
134 ),
135 template_branch: str = typer.Option(
136 None,
137 "--template-branch",
138 help="Custom template branch. Defaults to 'main'.",
139 ),
140 path_to_template: Annotated[
141 Path | None,
142 typer.Option(
143 "--path-to-template",
144 help=(
145 "Directory where template.yml will be created "
146 "(defaults to <TARGET>/.rhiza). "
147 "Use '.' to keep the file in the project root."
148 ),
149 ),
150 ] = None,
151) -> None:
152 r"""Initialize or validate .rhiza/template.yml.
154 Creates a default `.rhiza/template.yml` configuration file if one
155 doesn't exist, or validates the existing configuration.
157 The default template includes common project files based on the language.
158 The --git-host option determines which CI/CD configuration to include:
159 - github: includes .github folder (GitHub Actions workflows)
160 - gitlab: includes .gitlab-ci.yml (GitLab CI configuration)
162 The --language option determines the project type and files created:
163 - python: creates pyproject.toml, src/, and Python project structure
164 - go: creates minimal structure (you'll need to run 'go mod init')
166 Examples:
167 rhiza init
168 rhiza init --language go
169 rhiza init --language python --git-host github
170 rhiza init --git-host gitlab
171 rhiza init --template-repository myorg/my-templates
172 rhiza init --template-repository myorg/my-templates --template-branch develop
173 rhiza init /path/to/project
174 rhiza init .. --language go
175 rhiza init --path-to-template /custom/rhiza
176 rhiza init --path-to-template .
177 """
178 template_file = None
179 if path_to_template is not None:
180 template_file = path_to_template / "template.yml"
181 if not init_cmd(
182 target,
183 project_name=project_name,
184 package_name=package_name,
185 with_dev_dependencies=with_dev_dependencies,
186 git_host=git_host,
187 language=language,
188 template_repository=template_repository,
189 template_branch=template_branch,
190 template_file=template_file,
191 ):
192 raise typer.Exit(code=1)
195@app.command()
196def sync(
197 target: Annotated[
198 Path,
199 typer.Argument(
200 exists=True,
201 file_okay=False,
202 dir_okay=True,
203 help="Target git repository (defaults to current directory)",
204 ),
205 ] = Path("."),
206 branch: str = typer.Option("main", "--branch", "-b", help="Rhiza branch to use"),
207 target_branch: str = typer.Option(
208 None,
209 "--target-branch",
210 "--checkout-branch",
211 help="Create and checkout a new branch in the target repository for changes",
212 ),
213 strategy: str = typer.Option(
214 "merge",
215 "--strategy",
216 "-s",
217 help="Sync strategy: 'merge' (3-way merge preserving local changes) or 'diff' (dry-run showing changes)",
218 ),
219 path_to_template: Annotated[
220 Path | None,
221 typer.Option(
222 "--path-to-template",
223 help=(
224 "Directory containing template.yml and where template.lock will be written "
225 "(defaults to <TARGET>/.rhiza). "
226 "Use '.' to keep both files in the project root."
227 ),
228 ),
229 ] = None,
230) -> None:
231 r"""Sync templates using diff/merge, preserving local customisations.
233 This is the primary command for keeping your project up to date with
234 the template repository. It replaces the deprecated ``materialize`` command.
236 On **first sync** (no lock file) the command copies all template files and
237 records the current template HEAD in `.rhiza/template.lock`. On
238 **subsequent syncs** it computes the diff between the last-synced commit
239 and the current HEAD then applies it via ``git apply -3`` so local edits
240 are preserved.
242 The command tracks the last-synced template commit in
243 `.rhiza/template.lock`. On subsequent syncs it computes the diff
244 between two snapshots of the template:
246 \b
247 - base: the template at the last-synced commit
248 - upstream: the template at the current branch HEAD
249 - local: the file in your project (possibly customised)
251 Files that changed only upstream are updated automatically.
252 Files that changed only locally are left untouched.
253 Files that changed in both places are merged; conflicts are marked
254 with standard git conflict markers for manual resolution.
256 Strategies:
257 \b
258 - merge: 3-way merge preserving local changes (default)
259 - diff: dry-run showing what would change
261 Examples:
262 rhiza sync
263 rhiza sync --strategy diff
264 rhiza sync --branch develop
265 rhiza sync --target-branch feature/update-templates
266 rhiza sync --path-to-template /custom/rhiza
267 rhiza sync --path-to-template .
268 """
269 if strategy not in ("merge", "diff"):
270 typer.echo(f"Unknown strategy: {strategy}. Must be 'merge' or 'diff'.")
271 raise typer.Exit(code=1)
272 template_file = lock_file = None
273 if path_to_template is not None:
274 template_file = path_to_template / "template.yml"
275 lock_file = path_to_template / "template.lock"
276 with _exit_on_error(subprocess.CalledProcessError, RuntimeError, ValueError):
277 sync_cmd(target, branch, target_branch, strategy, template_file=template_file, lock_file=lock_file)
280@app.command()
281def status(
282 target: Annotated[
283 Path,
284 typer.Argument(
285 help="Path to target repository",
286 ),
287 ] = Path("."),
288) -> None:
289 """Show the current sync status from template.lock."""
290 with _exit_on_error(FileNotFoundError, ValueError, TypeError, yaml.YAMLError):
291 status_cmd(target.resolve())
294@app.command()
295def tree(
296 target: Annotated[
297 Path,
298 typer.Argument(
299 help="Path to target repository",
300 ),
301 ] = Path("."),
302) -> None:
303 r"""List files managed by Rhiza in a tree-style view.
305 Reads .rhiza/template.lock and displays the files that were synced
306 from the template repository as a directory tree.
308 Examples:
309 rhiza tree
310 rhiza tree /path/to/project
311 """
312 with _exit_on_error(FileNotFoundError, ValueError, TypeError, yaml.YAMLError):
313 tree_cmd(target.resolve())
316@app.command()
317def validate(
318 target: Annotated[
319 Path,
320 typer.Argument(
321 exists=True,
322 file_okay=False,
323 dir_okay=True,
324 help="Target git repository (defaults to current directory)",
325 ),
326 ] = Path("."),
327 path_to_template: Annotated[
328 Path | None,
329 typer.Option(
330 "--path-to-template",
331 help=(
332 "Directory containing template.yml "
333 "(defaults to <TARGET>/.rhiza). "
334 "Use '.' to keep the file in the project root."
335 ),
336 ),
337 ] = None,
338) -> None:
339 r"""Validate Rhiza template configuration.
341 Validates the .rhiza/template.yml file to ensure it is syntactically
342 correct and semantically valid.
344 Performs comprehensive validation:
345 - Checks if template.yml exists
346 - Validates YAML syntax
347 - Verifies required fields are present (template-repository, include)
348 - Validates field types and formats
349 - Ensures repository name follows owner/repo format
350 - Confirms include paths are not empty
353 Returns exit code 0 on success, 1 on validation failure.
355 Examples:
356 rhiza validate
357 rhiza validate /path/to/project
358 rhiza validate ..
359 rhiza validate --path-to-template /custom/rhiza
360 rhiza validate --path-to-template .
361 """
362 template_file = None
363 if path_to_template is not None:
364 template_file = path_to_template / "template.yml"
365 if not validate_cmd(target, template_file=template_file):
366 raise typer.Exit(code=1)
369@app.command()
370def migrate(
371 target: Annotated[
372 Path,
373 typer.Argument(
374 exists=True,
375 file_okay=False,
376 dir_okay=True,
377 help="Target git repository (defaults to current directory)",
378 ),
379 ] = Path("."),
380) -> None:
381 r"""Migrate project to the new .rhiza folder structure.
383 This command helps transition projects to use the new `.rhiza/` folder
384 structure for storing Rhiza state and configuration files. It performs
385 the following migrations:
387 - Creates the `.rhiza/` directory in the project root
388 - Moves `.github/rhiza/template.yml` or `.github/template.yml` to `.rhiza/template.yml`
389 - Moves `.rhiza.history` to `.rhiza/history`
391 The new `.rhiza/` folder structure separates Rhiza's state and configuration
392 from the `.github/` directory, providing better organization.
394 If files already exist in `.rhiza/`, the migration will skip them and leave
395 the old files in place. You can manually remove old files after verifying
396 the migration was successful.
398 Examples:
399 rhiza migrate
400 rhiza migrate /path/to/project
401 """
402 migrate_cmd(target)
405@app.command(name="list")
406def list_repos(
407 topic: str = typer.Option(
408 "rhiza",
409 "--topic",
410 "-t",
411 help="GitHub topic to search for (default: 'rhiza')",
412 ),
413) -> None:
414 r"""List GitHub repositories tagged with a given topic.
416 Queries the GitHub Search API for repositories tagged with the
417 specified topic and displays them in a formatted table with the
418 repository name, description, and last-updated date.
420 Set the ``GITHUB_TOKEN`` environment variable to avoid API rate limits.
422 Examples:
423 rhiza list
424 rhiza list --topic rhiza-go
425 """
426 if not list_repos_cmd(topic):
427 raise typer.Exit(code=1)
430@app.command()
431def uninstall(
432 target: Annotated[
433 Path,
434 typer.Argument(
435 exists=True,
436 file_okay=False,
437 dir_okay=True,
438 help="Target git repository (defaults to current directory)",
439 ),
440 ] = Path("."),
441 force: bool = typer.Option(
442 False,
443 "--force",
444 "-y",
445 help="Skip confirmation prompt and proceed with deletion",
446 ),
447) -> None:
448 r"""Remove all Rhiza-managed files from the repository.
450 Reads the `.rhiza/history` file and removes all files that were
451 previously synced by Rhiza templates. This provides a clean
452 way to uninstall all template-managed files from a project.
454 The command will:
455 - Read the list of files from `.rhiza.history`
456 - Prompt for confirmation (unless --force is used)
457 - Delete all listed files that exist
458 - Remove empty directories left behind
459 - Delete the `.rhiza.history` file itself
461 Use this command when you want to completely remove Rhiza templates
462 from your project.
464 Examples:
465 rhiza uninstall
466 rhiza uninstall --force
467 rhiza uninstall /path/to/project
468 rhiza uninstall /path/to/project -y
469 """
470 with _exit_on_error(RuntimeError):
471 uninstall_cmd(target, force)
474@app.command()
475def summarise(
476 target: Annotated[
477 Path,
478 typer.Argument(
479 exists=True,
480 file_okay=False,
481 dir_okay=True,
482 help="Target git repository (defaults to current directory)",
483 ),
484 ] = Path("."),
485 output: Annotated[
486 Path | None,
487 typer.Option(
488 "--output",
489 "-o",
490 help="Output file path (defaults to stdout)",
491 ),
492 ] = None,
493) -> None:
494 r"""Generate a summary of staged changes for PR descriptions.
496 Analyzes staged git changes and generates a structured PR description
497 that includes:
499 - Summary statistics (files added/modified/deleted)
500 - Changes categorized by type (workflows, configs, docs, tests, etc.)
501 - Template repository information
502 - Last sync date
504 This is useful when creating pull requests after running `rhiza sync`
505 to provide reviewers with a clear overview of what changed.
507 Examples:
508 rhiza summarise
509 rhiza summarise --output pr-description.md
510 rhiza summarise /path/to/project -o description.md
512 Typical workflow:
513 rhiza sync
514 git add .
515 rhiza summarise --output pr-body.md
516 gh pr create --title "chore: Sync with rhiza" --body-file pr-body.md
517 """
518 with _exit_on_error(RuntimeError):
519 summarise_cmd(target, output)