Coverage for src / rhiza / commands / init.py: 100%
119 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-02-12 20:13 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-02-12 20:13 +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 sys
12from pathlib import Path
14import typer
15from jinja2 import Template
16from loguru import logger
18from rhiza.commands.validate import validate
19from rhiza.models import RhizaTemplate
22def _normalize_package_name(name: str) -> str:
23 """Normalize a string into a valid Python package name.
25 Args:
26 name: The input string (e.g., project name).
28 Returns:
29 A valid Python identifier safe for use as a package name.
30 """
31 name = re.sub(r"[^a-zA-Z0-9_]", "_", name)
32 if name[0].isdigit():
33 name = f"_{name}"
34 if keyword.iskeyword(name):
35 name = f"{name}_"
36 return name
39def _validate_git_host(git_host: str | None) -> str | None:
40 """Validate git_host parameter.
42 Args:
43 git_host: Git hosting platform.
45 Returns:
46 Validated git_host or None.
48 Raises:
49 ValueError: If git_host is invalid.
50 """
51 if git_host is not None:
52 git_host = git_host.lower()
53 if git_host not in ["github", "gitlab"]:
54 logger.error(f"Invalid git-host: {git_host}. Must be 'github' or 'gitlab'")
55 raise ValueError(f"Invalid git-host: {git_host}. Must be 'github' or 'gitlab'") # noqa: TRY003
56 return git_host
59def _prompt_git_host() -> str:
60 """Prompt user for git hosting platform.
62 Returns:
63 Git hosting platform choice.
64 """
65 if sys.stdin.isatty():
66 logger.info("Where will your project be hosted?")
67 git_host = typer.prompt(
68 "Target Git hosting platform (github/gitlab)",
69 type=str,
70 default="github",
71 ).lower()
73 while git_host not in ["github", "gitlab"]:
74 logger.warning(f"Invalid choice: {git_host}. Please choose 'github' or 'gitlab'")
75 git_host = typer.prompt(
76 "Target Git hosting platform (github/gitlab)",
77 type=str,
78 default="github",
79 ).lower()
80 else:
81 git_host = "github"
82 logger.debug("Non-interactive mode detected, defaulting to github")
84 return str(git_host)
87def _get_default_templates_for_host(git_host: str) -> list[str]:
88 """Get default templates based on git hosting platform.
90 Args:
91 git_host: Git hosting platform.
93 Returns:
94 List of template names.
95 """
96 common = ["core", "tests", "book", "marimo", "presentation"]
97 if git_host == "gitlab":
98 return [*common, "gitlab"]
99 else:
100 return [*common, "github"]
103# def _get_include_paths_for_host(git_host: str) -> list[str]:
104# """Get include paths based on git hosting platform (legacy, path-based).
105#
106# Args:
107# git_host: Git hosting platform.
108#
109# Returns:
110# List of include paths.
111# """
112# if git_host == "gitlab":
113# return [
114# ".rhiza",
115# ".gitlab",
116# ".gitlab-ci.yml",
117# ".editorconfig",
118# ".gitignore",
119# ".pre-commit-config.yaml",
120# "ruff.toml",
121# "Makefile",
122# "pytest.ini",
123# "book",
124# "presentation",
125# "tests",
126# ]
127# else:
128# return [
129# ".rhiza",
130# ".github",
131# ".editorconfig",
132# ".gitignore",
133# ".pre-commit-config.yaml",
134# "ruff.toml",
135# "Makefile",
136# "pytest.ini",
137# "book",
138# "presentation",
139# "tests",
140# ]
143def _create_template_file(
144 target: Path,
145 git_host: str,
146 template_repository: str | None = None,
147 template_branch: str | None = None,
148) -> None:
149 """Create default template.yml file.
151 Args:
152 target: Target repository path.
153 git_host: Git hosting platform.
154 template_repository: Custom template repository (format: owner/repo).
155 template_branch: Custom template branch.
156 """
157 rhiza_dir = target / ".rhiza"
158 template_file = rhiza_dir / "template.yml"
160 if template_file.exists():
161 return
163 logger.info("Creating default .rhiza/template.yml")
164 logger.debug("Using default template configuration")
166 # Use custom template repository/branch if provided, otherwise use defaults
167 repo = template_repository or "jebel-quant/rhiza"
168 branch = template_branch or "main"
170 # Log when custom values are used
171 if template_repository:
172 logger.info(f"Using custom template repository: {repo}")
173 if template_branch:
174 logger.info(f"Using custom template branch: {branch}")
176 templates = _get_default_templates_for_host(git_host)
177 logger.info(f"Using template-based configuration with templates: {', '.join(templates)}")
178 default_template = RhizaTemplate(
179 template_repository=repo,
180 template_branch=branch,
181 templates=templates,
182 )
184 logger.debug(f"Writing default template to: {template_file}")
185 default_template.to_yaml(template_file)
187 logger.success("✓ Created .rhiza/template.yml")
188 logger.info("""
189Next steps:
190 1. Review and customize .rhiza/template.yml to match your project needs
191 2. Run 'rhiza materialize' to inject templates into your repository
192""")
195def _create_python_package(target: Path, project_name: str, package_name: str) -> None:
196 """Create basic Python package structure.
198 Args:
199 target: Target repository path.
200 project_name: Project name.
201 package_name: Package name.
202 """
203 src_folder = target / "src" / package_name
204 if (target / "src").exists():
205 return
207 logger.info(f"Creating Python package structure: {src_folder}")
208 src_folder.mkdir(parents=True)
210 # Create __init__.py
211 init_file = src_folder / "__init__.py"
212 logger.debug(f"Creating {init_file}")
213 init_file.touch()
215 template_content = importlib.resources.files("rhiza").joinpath("_templates/basic/__init__.py.jinja2").read_text()
216 template = Template(template_content)
217 code = template.render(project_name=project_name)
218 init_file.write_text(code)
220 # Create main.py
221 main_file = src_folder / "main.py"
222 logger.debug(f"Creating {main_file} with example code")
223 main_file.touch()
225 template_content = importlib.resources.files("rhiza").joinpath("_templates/basic/main.py.jinja2").read_text()
226 template = Template(template_content)
227 code = template.render(project_name=project_name)
228 main_file.write_text(code)
229 logger.success(f"Created Python package structure in {src_folder}")
232def _create_pyproject_toml(target: Path, project_name: str, package_name: str, with_dev_dependencies: bool) -> None:
233 """Create pyproject.toml file.
235 Args:
236 target: Target repository path.
237 project_name: Project name.
238 package_name: Package name.
239 with_dev_dependencies: Whether to include dev dependencies.
240 """
241 pyproject_file = target / "pyproject.toml"
242 if pyproject_file.exists():
243 return
245 logger.info("Creating pyproject.toml with basic project metadata")
246 pyproject_file.touch()
248 template_content = importlib.resources.files("rhiza").joinpath("_templates/basic/pyproject.toml.jinja2").read_text()
249 template = Template(template_content)
250 code = template.render(
251 project_name=project_name,
252 package_name=package_name,
253 with_dev_dependencies=with_dev_dependencies,
254 )
255 pyproject_file.write_text(code)
256 logger.success("Created pyproject.toml")
259def _create_readme(target: Path) -> None:
260 """Create README.md file.
262 Args:
263 target: Target repository path.
264 """
265 readme_file = target / "README.md"
266 if readme_file.exists():
267 return
269 logger.info("Creating README.md")
270 readme_file.touch()
271 logger.success("Created README.md")
274def init(
275 target: Path,
276 project_name: str | None = None,
277 package_name: str | None = None,
278 with_dev_dependencies: bool = False,
279 git_host: str | None = None,
280 template_repository: str | None = None,
281 template_branch: str | None = None,
282) -> bool:
283 """Initialize or validate .rhiza/template.yml in the target repository.
285 Creates a default .rhiza/template.yml file if it doesn't exist,
286 or validates an existing one.
288 Args:
289 target: Path to the target directory. Defaults to the current working directory.
290 project_name: Custom project name. Defaults to target directory name.
291 package_name: Custom package name. Defaults to normalized project name.
292 with_dev_dependencies: Include development dependencies in pyproject.toml.
293 git_host: Target Git hosting platform ("github" or "gitlab"). Determines which
294 CI/CD configuration files to include. If None, will prompt user interactively.
295 template_repository: Custom template repository (format: owner/repo).
296 Defaults to 'jebel-quant/rhiza'.
297 template_branch: Custom template branch. Defaults to 'main'.
299 Returns:
300 bool: True if validation passes, False otherwise.
301 """
302 target = target.resolve()
303 git_host = _validate_git_host(git_host)
305 logger.info(f"Initializing Rhiza configuration in: {target}")
307 # Create .rhiza directory
308 rhiza_dir = target / ".rhiza"
309 logger.debug(f"Ensuring directory exists: {rhiza_dir}")
310 rhiza_dir.mkdir(parents=True, exist_ok=True)
312 # Determine git host
313 if git_host is None:
314 git_host = _prompt_git_host()
316 # Create template file
317 _create_template_file(target, git_host, template_repository, template_branch)
319 # Bootstrap Python project structure
320 if project_name is None:
321 project_name = target.name
322 if package_name is None:
323 package_name = _normalize_package_name(project_name)
325 logger.debug(f"Project name: {project_name}")
326 logger.debug(f"Package name: {package_name}")
328 _create_python_package(target, project_name, package_name)
329 _create_pyproject_toml(target, project_name, package_name, with_dev_dependencies)
330 _create_readme(target)
332 # Validate the template file
333 logger.debug("Validating template configuration")
334 return validate(target)