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