Coverage for src / rhiza / commands / init.py: 100%
202 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"""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
14from pathlib import Path
16import typer
17from jinja2 import Template
18from loguru import logger
20from rhiza.commands.list_repos import _DESC_WIDTH, _fetch_repos
21from rhiza.commands.validate import validate
22from rhiza.models import GitContext, GitHost, RhizaTemplate
25def _normalize_package_name(name: str) -> str:
26 """Normalize a string into a valid Python package name.
28 Args:
29 name: The input string (e.g., project name).
31 Returns:
32 A valid Python identifier safe for use as a package name.
33 """
34 name = re.sub(r"[^a-zA-Z0-9_]", "_", name)
35 if name[0].isdigit():
36 name = f"_{name}"
37 if keyword.iskeyword(name):
38 name = f"{name}_"
39 return name
42def _validate_git_host(git_host: str | None) -> GitHost | None:
43 """Validate git_host parameter.
45 Args:
46 git_host: Git hosting platform.
48 Returns:
49 Validated GitHost enum value or None.
51 Raises:
52 ValueError: If git_host is invalid.
53 """
54 if git_host is None:
55 return None
56 try:
57 return GitHost(git_host.lower())
58 except ValueError:
59 logger.error(f"Invalid git-host: {git_host}. Must be 'github' or 'gitlab'")
60 raise ValueError(f"Invalid git-host: {git_host}. Must be 'github' or 'gitlab'") from None # noqa: TRY003
63def _check_template_repository_reachable(template_repository: str, git_host: GitHost | str = GitHost.GITHUB) -> bool:
64 """Check if the template repository is reachable via git ls-remote.
66 Args:
67 template_repository: Repository in 'owner/repo' format.
68 git_host: Git hosting platform ('github' or 'gitlab'). Defaults to 'github'.
70 Returns:
71 True if the repository is reachable, False otherwise.
72 """
73 host_urls = {
74 GitHost.GITHUB: "https://github.com",
75 GitHost.GITLAB: "https://gitlab.com",
76 }
77 base_url = host_urls.get(git_host, "https://github.com")
78 repo_url = f"{base_url}/{template_repository}"
80 logger.debug(f"Checking reachability of template repository: {repo_url}")
81 try:
82 git_ctx = GitContext.default()
83 result = subprocess.run( # nosec B603 # noqa: S603
84 [git_ctx.executable, "ls-remote", "--exit-code", repo_url],
85 capture_output=True,
86 timeout=30,
87 env=git_ctx.env,
88 )
89 if result.returncode == 0:
90 logger.success(f"Template repository is reachable: {template_repository}")
91 return True
92 else:
93 logger.error(f"Template repository '{template_repository}' is not accessible at {repo_url}")
94 logger.error("Please check that the repository exists and you have access to it.")
95 return False
96 except subprocess.TimeoutExpired:
97 logger.error(f"Timed out while checking repository reachability: {repo_url}")
98 logger.error("Please check your network connection and try again.")
99 return False
100 except RuntimeError as e:
101 logger.warning(f"Could not verify template repository reachability: {e}")
102 return True # Don't block init if git is unavailable
105def _prompt_git_host() -> GitHost:
106 """Prompt user for git hosting platform.
108 Returns:
109 Git hosting platform choice as a GitHost enum value.
110 """
111 if sys.stdin.isatty():
112 logger.info("Where will your project be hosted?")
113 git_host = typer.prompt(
114 "Target Git hosting platform (github/gitlab)",
115 type=str,
116 default="github",
117 ).lower()
119 while git_host not in GitHost._value2member_map_:
120 logger.warning(f"Invalid choice: {git_host}. Please choose 'github' or 'gitlab'")
121 git_host = typer.prompt(
122 "Target Git hosting platform (github/gitlab)",
123 type=str,
124 default="github",
125 ).lower()
126 else:
127 git_host = "github"
128 logger.debug("Non-interactive mode detected, defaulting to github")
130 return GitHost(git_host)
133def _prompt_template_repository() -> str | None:
134 """Prompt the user to select a template repository from a list of rhiza-tagged repos.
136 Fetches repositories tagged with 'rhiza' from the GitHub API and presents
137 them as a numbered list. In non-interactive or offline scenarios the function
138 returns None so the caller falls back to the language default.
140 Returns:
141 The selected repository in 'owner/repo' format, or None if the user
142 accepts the default or selection is not possible.
143 """
144 if not sys.stdin.isatty():
145 logger.debug("Non-interactive mode detected, skipping template repository selection")
146 return None
148 try:
149 repos = _fetch_repos()
150 except urllib.error.URLError as exc:
151 logger.debug(f"Could not fetch repository list: {exc}")
152 return None
154 if not repos:
155 return None
157 # Display a compact numbered list
158 typer.echo("\nAvailable template repositories:")
159 for i, repo in enumerate(repos, start=1):
160 desc = repo.description[:_DESC_WIDTH] if repo.description else ""
161 typer.echo(f" {i:>2} {repo.full_name:<30} {desc}")
163 typer.echo("")
164 selection = typer.prompt(
165 "Select a template repository by number, or press Enter to use the default",
166 default="",
167 ).strip()
169 if not selection:
170 return None
172 try:
173 idx = int(selection)
174 if 1 <= idx <= len(repos):
175 chosen = repos[idx - 1].full_name
176 logger.info(f"Selected template repository: {chosen}")
177 return chosen
178 else:
179 logger.warning(f"Invalid selection '{idx}', using default repository")
180 return None
181 except ValueError:
182 logger.warning(f"Invalid input '{selection}', using default repository")
183 return None
186def _get_default_templates_for_host(git_host: GitHost | str) -> list[str]:
187 """Get default templates based on git hosting platform.
189 Args:
190 git_host: Git hosting platform.
192 Returns:
193 List of template names.
194 """
195 common = ["core", "tests", "book", "marimo", "presentation"]
196 if git_host == GitHost.GITLAB:
197 return [*common, "gitlab"]
198 else:
199 return [*common, "github"]
202def _display_path(path: Path, target: Path) -> Path:
203 """Return *path* relative to *target* when possible, otherwise the absolute path.
205 Args:
206 path: Path to display.
207 target: Base directory used as the reference point.
209 Returns:
210 A relative or absolute Path suitable for log messages.
211 """
212 return path.relative_to(target) if path.is_relative_to(target) else path
215def _create_template_file(
216 target: Path,
217 git_host: GitHost | str,
218 language: str = "python",
219 template_repository: str | None = None,
220 template_branch: str | None = None,
221 template_file: Path | None = None,
222) -> None:
223 """Create default template.yml file.
225 Args:
226 target: Target repository path.
227 git_host: Git hosting platform.
228 language: Programming language for the project (default: python).
229 template_repository: Custom template repository (format: owner/repo).
230 template_branch: Custom template branch.
231 template_file: Optional explicit path to write template.yml. When
232 ``None`` the default ``<target>/.rhiza/template.yml`` is used.
233 """
234 if template_file is None:
235 rhiza_dir = target / ".rhiza"
236 template_file = rhiza_dir / "template.yml"
238 if template_file.exists():
239 return
241 template_file.parent.mkdir(parents=True, exist_ok=True)
242 logger.info(f"Creating default {_display_path(template_file, target)}")
243 logger.debug("Using default template configuration")
245 # Use custom template repository/branch if provided, otherwise use language defaults
246 if template_repository:
247 repo = template_repository
248 logger.info(f"Using custom template repository: {repo}")
249 else:
250 # Default repositories by language
251 repo = "jebel-quant/rhiza-go" if language == "go" else "jebel-quant/rhiza"
252 logger.debug(f"Using default repository for {language}: {repo}")
254 branch = template_branch or "main"
256 # Log when custom values are used
257 if template_branch:
258 logger.info(f"Using custom template branch: {branch}")
260 templates = _get_default_templates_for_host(git_host)
261 logger.info(f"Using template-based configuration with templates: {', '.join(templates)}")
262 default_template = RhizaTemplate(
263 template_repository=repo,
264 template_branch=branch,
265 language=language,
266 templates=templates,
267 )
269 logger.debug(f"Writing default template to: {template_file}")
270 default_template.to_yaml(template_file)
272 logger.success(f"✓ Created {_display_path(template_file, target)}")
273 logger.info("""
274Next steps:
275 1. Review and customize .rhiza/template.yml to match your project needs
276 2. Run 'uvx rhiza sync' to inject templates into your repository
277""")
280def _create_python_package(target: Path, project_name: str, package_name: str) -> None:
281 """Create basic Python package structure.
283 Args:
284 target: Target repository path.
285 project_name: Project name.
286 package_name: Package name.
287 """
288 src_folder = target / "src" / package_name
289 test_folder = target / "tests"
291 if (target / "src").exists():
292 return
294 logger.info(f"Creating Python package structure: {src_folder}")
295 src_folder.mkdir(parents=True)
297 logger.info(f"Creating test folder: {test_folder}")
298 test_folder.mkdir(parents=True)
300 # Create __init__.py
301 init_file = src_folder / "__init__.py"
302 logger.debug(f"Creating {init_file}")
303 init_file.touch()
305 template_content = importlib.resources.files("rhiza").joinpath("_templates/basic/__init__.py.jinja2").read_text()
306 template = Template(template_content)
307 code = template.render(project_name=project_name)
308 init_file.write_text(code)
310 # Create main.py
311 main_file = src_folder / "main.py"
312 logger.debug(f"Creating {main_file} with example code")
313 main_file.touch()
315 template_content = importlib.resources.files("rhiza").joinpath("_templates/basic/main.py.jinja2").read_text()
316 template = Template(template_content)
317 code = template.render(project_name=project_name)
318 main_file.write_text(code)
319 logger.success(f"Created Python package structure in {src_folder}")
321 # Create main.py
322 test_file = test_folder / "test_main.py"
323 logger.debug(f"Creating {test_file} with example code")
324 test_file.touch()
326 template_content = importlib.resources.files("rhiza").joinpath("_templates/basic/test_main.py.jinja2").read_text()
327 template = Template(template_content)
328 code = template.render(project_name=project_name)
329 test_file.write_text(code)
330 # logger.success(f"Created Python package structure in {src_folder}")
333def _create_pyproject_toml(target: Path, project_name: str, package_name: str, with_dev_dependencies: bool) -> None:
334 """Create pyproject.toml file.
336 Args:
337 target: Target repository path.
338 project_name: Project name.
339 package_name: Package name.
340 with_dev_dependencies: Whether to include dev dependencies.
341 """
342 pyproject_file = target / "pyproject.toml"
343 if pyproject_file.exists():
344 return
346 logger.info("Creating pyproject.toml with basic project metadata")
347 pyproject_file.touch()
349 template_content = importlib.resources.files("rhiza").joinpath("_templates/basic/pyproject.toml.jinja2").read_text()
350 template = Template(template_content)
351 code = template.render(
352 project_name=project_name,
353 package_name=package_name,
354 with_dev_dependencies=with_dev_dependencies,
355 )
356 pyproject_file.write_text(code)
357 logger.success("Created pyproject.toml")
360def _create_readme(target: Path) -> None:
361 """Create README.md file.
363 Args:
364 target: Target repository path.
365 """
366 readme_file = target / "README.md"
367 if readme_file.exists():
368 return
370 logger.info("Creating README.md")
371 readme_file.touch()
372 logger.success("Created README.md")
375def init(
376 target: Path,
377 project_name: str | None = None,
378 package_name: str | None = None,
379 with_dev_dependencies: bool = False,
380 git_host: str | None = None,
381 language: str = "python",
382 template_repository: str | None = None,
383 template_branch: str | None = None,
384 template_file: Path | None = None,
385) -> bool:
386 """Initialize or validate .rhiza/template.yml in the target repository.
388 Creates a default .rhiza/template.yml file if it doesn't exist,
389 or validates an existing one.
391 Args:
392 target: Path to the target directory. Defaults to the current working directory.
393 project_name: Custom project name. Defaults to target directory name.
394 package_name: Custom package name. Defaults to normalized project name.
395 with_dev_dependencies: Include development dependencies in pyproject.toml.
396 git_host: Target Git hosting platform ("github" or "gitlab"). Determines which
397 CI/CD configuration files to include. If None, will prompt user interactively.
398 language: Programming language for the project (default: python).
399 Supported: python, go. Determines which project files to create.
400 template_repository: Custom template repository (format: owner/repo).
401 Defaults to 'jebel-quant/rhiza' for Python or 'jebel-quant/rhiza-go' for Go.
402 template_branch: Custom template branch. Defaults to 'main'.
403 template_file: Optional explicit path to write template.yml. When
404 ``None`` the default ``<target>/.rhiza/template.yml`` is used.
406 Returns:
407 bool: True if validation passes, False otherwise.
408 """
409 target = target.resolve()
410 git_host = _validate_git_host(git_host)
412 logger.info(f"Initializing Rhiza configuration in: {target}")
413 logger.info(f"Project language: {language}")
415 # Create .rhiza directory (always; project structure lives there regardless of
416 # where template.yml is placed)
417 rhiza_dir = target / ".rhiza"
418 logger.debug(f"Ensuring directory exists: {rhiza_dir}")
419 rhiza_dir.mkdir(parents=True, exist_ok=True)
421 # Determine git host
422 if git_host is None:
423 git_host = _prompt_git_host()
425 # When no template repository is specified and no config file exists yet,
426 # offer the user an interactive selection from discovered rhiza repos.
427 resolved_template_file = template_file if template_file is not None else target / ".rhiza" / "template.yml"
428 if template_repository is None and not resolved_template_file.exists():
429 template_repository = _prompt_template_repository()
431 # Validate template repository reachability early if a custom one is specified
432 if template_repository is not None and not _check_template_repository_reachable(template_repository, git_host):
433 return False
435 # Create template file with language
436 _create_template_file(target, git_host, language, template_repository, template_branch, template_file)
438 # Bootstrap project structure based on language
439 if language == "python":
440 # Python-specific setup
441 if project_name is None:
442 project_name = target.name
443 if package_name is None:
444 package_name = _normalize_package_name(project_name)
446 logger.debug(f"Project name: {project_name}")
447 logger.debug(f"Package name: {package_name}")
449 _create_python_package(target, project_name, package_name)
450 _create_pyproject_toml(target, project_name, package_name, with_dev_dependencies)
451 _create_readme(target)
452 elif language == "go":
453 # Go-specific setup - just create README, user should run go mod init
454 _create_readme(target)
455 logger.info("For Go projects, run 'go mod init <module-name>' to initialize the module")
456 else:
457 # Unknown language - just create README
458 logger.warning(f"Unknown language '{language}', creating minimal structure")
459 _create_readme(target)
461 # Validate the template file
462 logger.debug("Validating template configuration")
463 return validate(target, template_file=template_file)