Coverage for src / rhiza / commands / init.py: 100%
320 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-06-15 18:22 +0000
« prev ^ index » next coverage.py v7.14.0, created at 2026-06-15 18:22 +0000
1"""Command to initialize or validate .rhiza/template.yml.
3This module provides the init command that creates or validates the
4.rhiza/template.yml file, which defines where templates come from
5and what paths are governed by Rhiza.
6"""
8import importlib.resources
9import keyword
10import re
11import subprocess # nosec B404
12import sys
13import urllib.error
14import urllib.parse
15from pathlib import Path
17import typer
18from jinja2 import Template
19from loguru import logger
21from rhiza.commands.list_repos import _DESC_WIDTH, _fetch_repos
22from rhiza.commands.validate import validate
23from rhiza.models import GitContext, GitHost
26def _normalize_package_name(name: str) -> str:
27 """Normalize a string into a valid Python package name.
29 Args:
30 name: The input string (e.g., project name).
32 Returns:
33 A valid Python identifier safe for use as a package name.
34 """
35 name = re.sub(r"[^a-zA-Z0-9_]", "_", name)
36 if name[0].isdigit():
37 name = f"_{name}"
38 if keyword.iskeyword(name):
39 name = f"{name}_"
40 return name
43def _validate_git_host(git_host: str | None) -> GitHost | None:
44 """Validate git_host parameter.
46 Args:
47 git_host: Git hosting platform.
49 Returns:
50 Validated GitHost enum value or None.
52 Raises:
53 ValueError: If git_host is invalid.
54 """
55 if git_host is None:
56 return None
57 try:
58 return GitHost(git_host.lower())
59 except ValueError:
60 logger.error(f"Invalid git-host: {git_host}. Must be 'github' or 'gitlab'")
61 raise ValueError(f"Invalid git-host: {git_host}. Must be 'github' or 'gitlab'") from None # noqa: TRY003
64def _check_template_repository_reachable(template_repository: str, git_host: GitHost = GitHost.GITHUB) -> bool:
65 """Check if the template repository is reachable via git ls-remote.
67 Args:
68 template_repository: Repository in 'owner/repo' format.
69 git_host: Git hosting platform ('github' or 'gitlab'). Defaults to 'github'.
71 Returns:
72 True if the repository is reachable, False otherwise.
73 """
74 host_urls = {
75 GitHost.GITHUB: "https://github.com",
76 GitHost.GITLAB: "https://gitlab.com",
77 }
78 base_url = host_urls.get(git_host, "https://github.com")
79 repo_url = f"{base_url}/{template_repository}"
81 logger.debug(f"Checking reachability of template repository: {repo_url}")
82 try:
83 git_ctx = GitContext.default()
84 result = subprocess.run( # nosec B603 # noqa: S603
85 [git_ctx.executable, "ls-remote", "--exit-code", repo_url],
86 capture_output=True,
87 timeout=30,
88 env=git_ctx.env,
89 )
90 if result.returncode == 0:
91 logger.success(f"Template repository is reachable: {template_repository}")
92 return True
93 else:
94 logger.error(f"Template repository '{template_repository}' is not accessible at {repo_url}")
95 logger.error("Please check that the repository exists and you have access to it.")
96 return False
97 except subprocess.TimeoutExpired:
98 logger.error(f"Timed out while checking repository reachability: {repo_url}")
99 logger.error("Please check your network connection and try again.")
100 return False
101 except RuntimeError as e:
102 logger.warning(f"Could not verify template repository reachability: {e}")
103 return True # Don't block init if git is unavailable
106def _get_latest_tag(template_repository: str, git_host: GitHost | str = GitHost.GITHUB) -> str | None:
107 """Fetch the latest version tag from the template repository via git ls-remote.
109 Args:
110 template_repository: Repository in 'owner/repo' format.
111 git_host: Git hosting platform.
113 Returns:
114 Latest version tag (e.g. ``'v0.18.4'``), or ``None`` on error or when no
115 version tags exist.
116 """
117 if git_host == GitHost.GITLAB:
118 repo_url = f"https://gitlab.com/{template_repository}"
119 else:
120 repo_url = f"https://github.com/{template_repository}"
122 logger.debug(f"Fetching latest tag from {repo_url}")
123 try:
124 git_ctx = GitContext.default()
125 result = subprocess.run( # nosec B603 # noqa: S603
126 [git_ctx.executable, "ls-remote", "--tags", repo_url],
127 capture_output=True,
128 text=True,
129 timeout=30,
130 env=git_ctx.env,
131 )
132 if result.returncode != 0:
133 logger.debug(f"git ls-remote --tags failed for {repo_url}")
134 return None
136 version_tags = []
137 for line in result.stdout.splitlines():
138 if "^{}" in line: # skip dereferenced annotated-tag objects
139 continue
140 parts = line.split("\t")
141 if len(parts) == 2 and parts[1].startswith("refs/tags/"):
142 tag = parts[1][len("refs/tags/") :]
143 if re.match(r"^v?\d+\.\d+", tag):
144 version_tags.append(tag)
146 if not version_tags:
147 logger.debug(f"No version tags found in {repo_url}")
148 return None
150 latest = max(version_tags, key=lambda t: tuple(int(x) for x in re.findall(r"\d+", t)))
151 logger.debug(f"Latest tag: {latest}")
153 except (subprocess.TimeoutExpired, RuntimeError, OSError) as exc:
154 logger.debug(f"Could not fetch latest tag from {repo_url}: {exc}")
155 return None
156 else:
157 return latest
160def _get_github_username(target: Path) -> str:
161 """Extract the GitHub/GitLab username (or org) from the origin remote URL.
163 Args:
164 target: Repository root directory.
166 Returns:
167 Username string, or ``"your-org"`` when detection fails.
168 """
169 try:
170 git_ctx = GitContext.default()
171 result = subprocess.run( # nosec B603 # noqa: S603
172 [git_ctx.executable, "remote", "get-url", "origin"],
173 capture_output=True,
174 text=True,
175 cwd=target,
176 env=git_ctx.env,
177 )
178 if result.returncode != 0:
179 return "your-org"
180 url = result.stdout.strip()
181 # ssh: git@github.com:username/repo.git
182 ssh_match = re.match(r"git@[^:]+:([^/]+)/", url)
183 if ssh_match:
184 return ssh_match.group(1)
185 # https: https://github.com/username/repo.git
186 https_match = re.match(r"https?://[^/]+/([^/]+)/", url)
187 if https_match:
188 return https_match.group(1)
189 except (RuntimeError, OSError):
190 pass
191 return "your-org"
194def _detect_git_host(target: Path) -> GitHost | None:
195 """Infer the git hosting platform from the repository's origin remote URL.
197 Args:
198 target: Repository root directory.
200 Returns:
201 Detected :class:`GitHost`, or ``None`` when detection is not possible
202 (no git repo, no origin remote, or unrecognised host).
203 """
204 try:
205 git_ctx = GitContext.default()
206 result = subprocess.run( # nosec B603 # noqa: S603
207 [git_ctx.executable, "remote", "get-url", "origin"],
208 capture_output=True,
209 text=True,
210 cwd=target,
211 env=git_ctx.env,
212 )
213 if result.returncode != 0:
214 return None
215 url = result.stdout.strip()
217 host: str | None = None
218 if "://" in url:
219 host = urllib.parse.urlparse(url).hostname
220 else:
221 # Handle SCP-like git remotes, e.g. git@github.com:org/repo.git
222 match = re.match(r"^(?:[^@]+@)?([^:]+):.+$", url)
223 if match:
224 host = match.group(1)
226 host = host.lower() if host else None
227 if host == "github.com":
228 logger.debug(f"Detected git host: github (from {url})")
229 return GitHost.GITHUB
230 if host == "gitlab.com":
231 logger.debug(f"Detected git host: gitlab (from {url})")
232 return GitHost.GITLAB
233 except (RuntimeError, OSError):
234 return None
235 else:
236 return None
239def _prompt_git_host() -> GitHost:
240 """Prompt user for git hosting platform.
242 Returns:
243 Git hosting platform choice as a GitHost enum value.
244 """
245 if sys.stdin.isatty():
246 logger.info("Where will your project be hosted?")
247 git_host = typer.prompt(
248 "Target Git hosting platform (github/gitlab)",
249 type=str,
250 default="github",
251 ).lower()
253 while git_host not in GitHost._value2member_map_:
254 logger.warning(f"Invalid choice: {git_host}. Please choose 'github' or 'gitlab'")
255 git_host = typer.prompt(
256 "Target Git hosting platform (github/gitlab)",
257 type=str,
258 default="github",
259 ).lower()
260 else:
261 git_host = "github"
262 logger.debug("Non-interactive mode detected, defaulting to github")
264 return GitHost(git_host)
267def _prompt_template_repository() -> str | None:
268 """Prompt the user to select a template repository from a list of rhiza-tagged repos.
270 Fetches repositories tagged with 'rhiza' from the GitHub API and presents
271 them as a numbered list. In non-interactive or offline scenarios the function
272 returns None so the caller falls back to the language default.
274 Returns:
275 The selected repository in 'owner/repo' format, or None if the user
276 accepts the default or selection is not possible.
277 """
278 if not sys.stdin.isatty():
279 logger.debug("Non-interactive mode detected, skipping template repository selection")
280 return None
282 try:
283 repos = _fetch_repos()
284 except urllib.error.URLError as exc:
285 logger.debug(f"Could not fetch repository list: {exc}")
286 return None
288 if not repos:
289 return None
291 # Display a compact numbered list
292 typer.echo("\nAvailable template repositories:")
293 for i, repo in enumerate(repos, start=1):
294 desc = repo.description[:_DESC_WIDTH] if repo.description else ""
295 typer.echo(f" {i:>2} {repo.full_name:<30} {desc}")
297 typer.echo("")
298 selection = typer.prompt(
299 "Select a template repository by number",
300 default="1",
301 ).strip()
303 try:
304 idx = int(selection)
305 if 1 <= idx <= len(repos):
306 chosen = repos[idx - 1].full_name
307 logger.info(f"Selected template repository: {chosen}")
308 return chosen
309 else:
310 logger.warning(f"Invalid selection '{idx}', using default repository")
311 return None
312 except ValueError:
313 logger.warning(f"Invalid input '{selection}', using default repository")
314 return None
317def _get_default_profile_for_host(git_host: GitHost | str) -> str:
318 """Return the profile name that matches the git hosting platform.
320 Args:
321 git_host: Git hosting platform.
323 Returns:
324 Profile name (e.g. ``"gitlab-project"`` or ``"github-project"``).
325 """
326 if git_host == GitHost.GITLAB:
327 return "gitlab-project"
328 return "github-project"
331def _display_path(path: Path, target: Path) -> Path:
332 """Return *path* relative to *target* when possible, otherwise the absolute path.
334 Args:
335 path: Path to display.
336 target: Base directory used as the reference point.
338 Returns:
339 A relative or absolute Path suitable for log messages.
340 """
341 return path.relative_to(target) if path.is_relative_to(target) else path
344def _create_template_file(
345 target: Path,
346 git_host: GitHost | str,
347 language: str = "python",
348 template_repository: str | None = None,
349 template_branch: str | None = None,
350 template_file: Path | None = None,
351) -> None:
352 """Create default template.yml file.
354 Args:
355 target: Target repository path.
356 git_host: Git hosting platform.
357 language: Programming language for the project (default: python).
358 template_repository: Custom template repository (format: owner/repo).
359 template_branch: Custom template branch.
360 template_file: Optional explicit path to write template.yml. When
361 ``None`` the default ``<target>/.rhiza/template.yml`` is used.
362 """
363 if template_file is None:
364 rhiza_dir = target / ".rhiza"
365 template_file = rhiza_dir / "template.yml"
367 if template_file.exists():
368 return
370 template_file.parent.mkdir(parents=True, exist_ok=True)
371 logger.info(f"Creating default {_display_path(template_file, target)}")
372 logger.debug("Using default template configuration")
374 # Use custom template repository/branch if provided, otherwise use language defaults
375 if template_repository:
376 repo = template_repository
377 logger.info(f"Using custom template repository: {repo}")
378 else:
379 # Default repositories by language
380 repo = "jebel-quant/rhiza-go" if language == "go" else "jebel-quant/rhiza"
381 logger.debug(f"Using default repository for {language}: {repo}")
383 if template_branch:
384 branch = template_branch
385 logger.info(f"Using custom template branch: {branch}")
386 else:
387 latest = _get_latest_tag(repo, git_host)
388 if latest:
389 branch = latest
390 logger.info(f"Using latest tag: {branch}")
391 else:
392 branch = "main"
393 logger.warning("Could not determine latest tag, falling back to 'main'")
395 profile = _get_default_profile_for_host(git_host)
396 logger.info(f"Using profile: {profile}")
398 jinja_src = importlib.resources.files("rhiza").joinpath("_templates/basic/template.yml.jinja2").read_text()
399 rendered = Template(jinja_src, keep_trailing_newline=True).render(
400 template_repository=repo,
401 template_branch=branch,
402 git_host=str(git_host),
403 language=language,
404 profile=profile,
405 )
407 logger.debug(f"Writing default template to: {template_file}")
408 template_file.write_text(rendered)
410 logger.success(f"✓ Created {_display_path(template_file, target)}")
411 logger.info("""
412Next steps:
413 1. Review and customize .rhiza/template.yml to match your project needs
414 2. Run 'uvx rhiza sync' to inject templates into your repository
415""")
418def _create_python_package(target: Path, project_name: str, package_name: str) -> None:
419 """Create basic Python package structure.
421 Args:
422 target: Target repository path.
423 project_name: Project name.
424 package_name: Package name.
425 """
426 src_folder = target / "src" / package_name
427 test_folder = target / "tests"
429 if (target / "src").exists():
430 return
432 logger.info(f"Creating Python package structure: {src_folder}")
433 src_folder.mkdir(parents=True)
435 logger.info(f"Creating test folder: {test_folder}")
436 test_folder.mkdir(parents=True)
438 # Create __init__.py
439 init_file = src_folder / "__init__.py"
440 logger.debug(f"Creating {init_file}")
441 init_file.touch()
443 template_content = importlib.resources.files("rhiza").joinpath("_templates/basic/__init__.py.jinja2").read_text()
444 template = Template(template_content, keep_trailing_newline=True)
445 code = template.render(project_name=project_name)
446 init_file.write_text(code)
448 # Create main.py
449 main_file = src_folder / "main.py"
450 logger.debug(f"Creating {main_file} with example code")
451 main_file.touch()
453 template_content = importlib.resources.files("rhiza").joinpath("_templates/basic/main.py.jinja2").read_text()
454 template = Template(template_content, keep_trailing_newline=True)
455 code = template.render(project_name=project_name)
456 main_file.write_text(code)
457 logger.success(f"Created Python package structure in {src_folder}")
459 # Create main.py
460 test_file = test_folder / "test_main.py"
461 logger.debug(f"Creating {test_file} with example code")
462 test_file.touch()
464 template_content = importlib.resources.files("rhiza").joinpath("_templates/basic/test_main.py.jinja2").read_text()
465 template = Template(template_content, keep_trailing_newline=True)
466 code = template.render(project_name=package_name)
467 test_file.write_text(code)
468 # logger.success(f"Created Python package structure in {src_folder}")
471def _create_pyproject_toml(
472 target: Path,
473 project_name: str,
474 package_name: str,
475 with_dev_dependencies: bool,
476 github_username: str = "your-org",
477) -> None:
478 """Create pyproject.toml file.
480 Args:
481 target: Target repository path.
482 project_name: Project name.
483 package_name: Package name.
484 with_dev_dependencies: Whether to include dev dependencies.
485 github_username: GitHub/GitLab username or org extracted from the origin remote.
486 """
487 pyproject_file = target / "pyproject.toml"
488 if pyproject_file.exists():
489 return
491 logger.info("Creating pyproject.toml with basic project metadata")
492 pyproject_file.touch()
494 template_content = importlib.resources.files("rhiza").joinpath("_templates/basic/pyproject.toml.jinja2").read_text()
495 template = Template(template_content, keep_trailing_newline=True)
496 code = template.render(
497 project_name=project_name,
498 package_name=package_name,
499 with_dev_dependencies=with_dev_dependencies,
500 github_username=github_username,
501 )
502 pyproject_file.write_text(code)
503 logger.success("Created pyproject.toml")
506def _create_uv_lock(target: Path) -> None:
507 """Run ``uv lock`` to generate the initial uv.lock file.
509 Args:
510 target: Repository root directory.
511 """
512 lock_file = target / "uv.lock"
513 if lock_file.exists():
514 return
516 logger.info("Generating uv.lock")
517 try:
518 result = subprocess.run( # nosec B603 B607
519 ["uv", "lock"], # noqa: S607
520 cwd=target,
521 capture_output=True,
522 text=True,
523 )
524 if result.returncode == 0:
525 logger.success("Created uv.lock")
526 else:
527 logger.warning(f"uv lock failed (exit {result.returncode}): {result.stderr.strip()}")
528 except (OSError, FileNotFoundError):
529 logger.warning("uv not found — skipping uv.lock generation. Run 'uv lock' manually.")
532def _create_makefile(target: Path) -> None:
533 """Create a minimal Makefile that bootstraps ``make sync`` before rhiza.mk exists.
535 Args:
536 target: Target repository path.
537 """
538 makefile = target / "Makefile"
539 if makefile.exists():
540 return
542 logger.info("Creating Makefile")
543 template_content = importlib.resources.files("rhiza").joinpath("_templates/basic/Makefile.jinja2").read_text()
544 makefile.write_text(Template(template_content, keep_trailing_newline=True).render())
545 logger.success("Created Makefile")
548def _create_mkdocs_yml(target: Path, project_name: str, username: str, git_host: GitHost | None = None) -> None:
549 """Create mkdocs.yml file.
551 Args:
552 target: Target repository path.
553 project_name: Project name.
554 username: GitHub/GitLab username or org extracted from the origin remote.
555 git_host: Git hosting platform; controls repo and pages URLs.
556 """
557 mkdocs_file = target / "mkdocs.yml"
558 if mkdocs_file.exists():
559 return
561 if git_host == GitHost.GITLAB:
562 repo_host = "gitlab.com"
563 pages_host = "gitlab.io"
564 else:
565 repo_host = "github.com"
566 pages_host = "github.io"
568 logger.info("Creating mkdocs.yml")
569 template_content = importlib.resources.files("rhiza").joinpath("_templates/basic/mkdocs.yml.jinja2").read_text()
570 mkdocs_file.write_text(
571 Template(template_content, keep_trailing_newline=True).render(
572 project_name=project_name,
573 username=username,
574 repo_host=repo_host,
575 pages_host=pages_host,
576 )
577 )
578 logger.success("Created mkdocs.yml")
581def _create_readme(target: Path) -> None:
582 """Create README.md file.
584 Args:
585 target: Target repository path.
586 """
587 readme_file = target / "README.md"
588 if readme_file.exists():
589 return
591 logger.info("Creating README.md")
592 readme_file.touch()
593 logger.success("Created README.md")
596def init(
597 target: Path,
598 project_name: str | None = None,
599 package_name: str | None = None,
600 with_dev_dependencies: bool = False,
601 git_host: str | None = None,
602 language: str = "python",
603 template_repository: str | None = None,
604 template_branch: str | None = None,
605 template_file: Path | None = None,
606) -> bool:
607 """Initialize or validate .rhiza/template.yml in the target repository.
609 Creates a default .rhiza/template.yml file if it doesn't exist,
610 or validates an existing one.
612 Args:
613 target: Path to the target directory. Defaults to the current working directory.
614 project_name: Custom project name. Defaults to target directory name.
615 package_name: Custom package name. Defaults to normalized project name.
616 with_dev_dependencies: Include development dependencies in pyproject.toml.
617 git_host: Target Git hosting platform ("github" or "gitlab"). Determines which
618 CI/CD configuration files to include. If None, will prompt user interactively.
619 language: Programming language for the project (default: python).
620 Supported: python, go. Determines which project files to create.
621 template_repository: Custom template repository (format: owner/repo).
622 Defaults to 'jebel-quant/rhiza' for Python or 'jebel-quant/rhiza-go' for Go.
623 template_branch: Custom template branch. Defaults to 'main'.
624 template_file: Optional explicit path to write template.yml. When
625 ``None`` the default ``<target>/.rhiza/template.yml`` is used.
627 Returns:
628 bool: True if validation passes, False otherwise.
629 """
630 target = target.resolve()
631 git_host = _validate_git_host(git_host)
633 git_ctx = GitContext.default()
634 result = subprocess.run( # nosec B603 # noqa: S603
635 [git_ctx.executable, "rev-parse", "--git-dir"],
636 capture_output=True,
637 cwd=target,
638 env=git_ctx.env,
639 )
640 if result.returncode != 0:
641 logger.error(f"{target} is not a git repository. Run 'git init' first.")
642 return False
644 logger.info(f"Initializing Rhiza configuration in: {target}")
645 logger.info(f"Project language: {language}")
647 # Create .rhiza directory (always; project structure lives there regardless of
648 # where template.yml is placed)
649 rhiza_dir = target / ".rhiza"
650 logger.debug(f"Ensuring directory exists: {rhiza_dir}")
651 rhiza_dir.mkdir(parents=True, exist_ok=True)
653 # Determine git host: explicit arg > remote URL detection > interactive prompt
654 if git_host is None:
655 git_host = _detect_git_host(target)
656 if git_host is not None:
657 logger.info(f"Detected git host from remote URL: {git_host}")
658 else:
659 git_host = _prompt_git_host()
661 # When no template repository is specified and no config file exists yet,
662 # offer the user an interactive selection from discovered rhiza repos.
663 resolved_template_file = template_file if template_file is not None else target / ".rhiza" / "template.yml"
664 if template_repository is None and not resolved_template_file.exists():
665 template_repository = _prompt_template_repository()
667 # Validate template repository reachability early if a custom one is specified
668 if template_repository is not None and not _check_template_repository_reachable(template_repository, git_host):
669 return False
671 # Create template file with language
672 _create_template_file(target, git_host, language, template_repository, template_branch, template_file)
674 # Bootstrap project structure based on language
675 if language == "python":
676 # Python-specific setup
677 if project_name is None:
678 project_name = target.name
679 if package_name is None:
680 package_name = _normalize_package_name(project_name)
682 logger.debug(f"Project name: {project_name}")
683 logger.debug(f"Package name: {package_name}")
685 github_username = _get_github_username(target)
686 _create_python_package(target, project_name, package_name)
687 _create_pyproject_toml(target, project_name, package_name, with_dev_dependencies, github_username)
688 _create_uv_lock(target)
689 _create_makefile(target)
690 _create_mkdocs_yml(target, project_name, github_username, git_host)
691 _create_readme(target)
692 elif language == "go":
693 # Go-specific setup - just create README, user should run go mod init
694 _create_readme(target)
695 logger.info("For Go projects, run 'go mod init <module-name>' to initialize the module")
696 else:
697 # Unknown language - just create README
698 logger.warning(f"Unknown language '{language}', creating minimal structure")
699 _create_readme(target)
701 # Validate the template file
702 logger.debug("Validating template configuration")
703 return validate(target, template_file=template_file)