Coverage for src / rhiza / commands / init.py: 94%
90 statements
« prev ^ index » next coverage.py v7.13.1, created at 2025-12-29 01:59 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2025-12-29 01:59 +0000
1"""Command to initialize or validate .github/rhiza/template.yml.
3This module provides the init command that creates or validates the
4.github/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 # Replace any character that is not a letter, number, or underscore with an underscore
32 name = re.sub(r"[^a-zA-Z0-9_]", "_", name)
34 # Ensure it doesn't start with a number
35 if name[0].isdigit():
36 name = f"_{name}"
38 # Ensure it's not a Python keyword
39 if keyword.iskeyword(name):
40 name = f"{name}_"
42 return name
45def init(
46 target: Path,
47 project_name: str | None = None,
48 package_name: str | None = None,
49 with_dev_dependencies: bool = False,
50 git_host: str | None = None,
51):
52 """Initialize or validate .github/rhiza/template.yml in the target repository.
54 Creates a default .github/rhiza/template.yml file if it doesn't exist,
55 or validates an existing one.
57 Args:
58 target: Path to the target directory. Defaults to the current working directory.
59 project_name: Custom project name. Defaults to target directory name.
60 package_name: Custom package name. Defaults to normalized project name.
61 with_dev_dependencies: Include development dependencies in pyproject.toml.
62 git_host: Target Git hosting platform ("github" or "gitlab"). Determines which
63 CI/CD configuration files to include. If None, will prompt user interactively.
65 Returns:
66 bool: True if validation passes, False otherwise.
67 """
68 # Convert to absolute path to avoid surprises
69 target = target.resolve()
71 # Validate git_host if provided
72 if git_host is not None:
73 git_host = git_host.lower()
74 if git_host not in ["github", "gitlab"]:
75 logger.error(f"Invalid git-host: {git_host}. Must be 'github' or 'gitlab'")
76 raise ValueError(f"Invalid git-host: {git_host}. Must be 'github' or 'gitlab'")
78 logger.info(f"Initializing Rhiza configuration in: {target}")
80 # Create .rhiza directory structure if it doesn't exist
81 # This is where Rhiza stores its configuration
82 rhiza_dir = target / ".rhiza"
83 logger.debug(f"Ensuring directory exists: {rhiza_dir}")
84 rhiza_dir.mkdir(parents=True, exist_ok=True)
86 # Define the template file path
87 template_file = rhiza_dir / "template.yml"
89 if not template_file.exists():
90 # Create default template.yml with sensible defaults
91 logger.info("Creating default .rhiza/template.yml")
92 logger.debug("Using default template configuration")
94 # Prompt for target git hosting platform if not provided
95 if git_host is None:
96 # Only prompt if running in an interactive terminal
97 if sys.stdin.isatty():
98 logger.info("Where will your project be hosted?")
99 git_host = typer.prompt(
100 "Target Git hosting platform (github/gitlab)",
101 type=str,
102 default="github",
103 ).lower()
105 # Validate the input
106 while git_host not in ["github", "gitlab"]:
107 logger.warning(f"Invalid choice: {git_host}. Please choose 'github' or 'gitlab'")
108 git_host = typer.prompt(
109 "Target Git hosting platform (github/gitlab)",
110 type=str,
111 default="github",
112 ).lower()
113 else:
114 # Non-interactive mode (e.g., tests), default to github
115 git_host = "github"
116 logger.debug("Non-interactive mode detected, defaulting to github")
118 # Adjust template based on target git hosting platform
119 # The template repository is always on GitHub (jebel-quant/rhiza)
120 # but we include different files based on where the target project will be
121 if git_host == "gitlab":
122 include_paths = [
123 ".rhiza", # .rhiza folder
124 ".gitlab", # .gitlab folder
125 ".gitlab-ci.yml", # GitLab CI configuration
126 ".editorconfig", # Editor configuration
127 ".gitignore", # Git ignore patterns
128 ".pre-commit-config.yaml", # Pre-commit hooks
129 "ruff.toml", # Ruff linter configuration
130 "Makefile", # Build and development tasks
131 "pytest.ini", # Pytest configuration
132 "book", # Documentation book
133 "presentation", # Presentation materials
134 "tests", # Test structure
135 ]
136 else:
137 include_paths = [
138 ".rhiza", # .rhiza folder
139 ".github", # GitHub configuration and workflows
140 ".editorconfig", # Editor configuration
141 ".gitignore", # Git ignore patterns
142 ".pre-commit-config.yaml", # Pre-commit hooks
143 "ruff.toml", # Ruff linter configuration
144 "Makefile", # Build and development tasks
145 "pytest.ini", # Pytest configuration
146 "book", # Documentation book
147 "presentation", # Presentation materials
148 "tests", # Test structure
149 ]
151 # Default template points to the jebel-quant/rhiza repository on GitHub
152 # and includes files appropriate for the target platform
153 default_template = RhizaTemplate(
154 template_repository="jebel-quant/rhiza",
155 template_branch="main",
156 # template_host is not set here - it defaults to "github" in the model
157 # because the template repository is on GitHub
158 include=include_paths,
159 )
161 # Write the default template to the file
162 logger.debug(f"Writing default template to: {template_file}")
163 default_template.to_yaml(template_file)
165 logger.success("✓ Created .rhiza/template.yml")
166 logger.info("""
167Next steps:
168 1. Review and customize .rhiza/template.yml to match your project needs
169 2. Run 'rhiza materialize' to inject templates into your repository
170""")
172 # Bootstrap basic Python project structure if it doesn't exist
173 # Get the name of the parent directory to use as package name
174 if project_name is None:
175 project_name = target.name
177 if package_name is None:
178 package_name = _normalize_package_name(project_name)
180 logger.debug(f"Project name: {project_name}")
181 logger.debug(f"Package name: {package_name}")
183 # Create src/{package_name} directory structure following src-layout
184 src_folder = target / "src" / package_name
185 if not (target / "src").exists():
186 logger.info(f"Creating Python package structure: {src_folder}")
187 src_folder.mkdir(parents=True)
189 # Create __init__.py to make it a proper Python package
190 init_file = src_folder / "__init__.py"
191 logger.debug(f"Creating {init_file}")
192 init_file.touch()
194 template_content = (
195 importlib.resources.files("rhiza").joinpath("_templates/basic/__init__.py.jinja2").read_text()
196 )
197 template = Template(template_content)
198 code = template.render(project_name=project_name)
199 init_file.write_text(code)
201 # Create main.py with a simple "Hello World" example
202 main_file = src_folder / "main.py"
203 logger.debug(f"Creating {main_file} with example code")
204 main_file.touch()
206 # Write example code to main.py
207 template_content = importlib.resources.files("rhiza").joinpath("_templates/basic/main.py.jinja2").read_text()
208 template = Template(template_content)
209 code = template.render(project_name=project_name)
210 main_file.write_text(code)
211 logger.success(f"Created Python package structure in {src_folder}")
213 # Create pyproject.toml if it doesn't exist
214 # This is the standard Python package metadata file (PEP 621)
215 pyproject_file = target / "pyproject.toml"
216 if not pyproject_file.exists():
217 logger.info("Creating pyproject.toml with basic project metadata")
218 pyproject_file.touch()
220 # Write minimal pyproject.toml content
221 template_content = (
222 importlib.resources.files("rhiza").joinpath("_templates/basic/pyproject.toml.jinja2").read_text()
223 )
224 template = Template(template_content)
225 code = template.render(
226 project_name=project_name,
227 package_name=package_name,
228 with_dev_dependencies=with_dev_dependencies,
229 )
230 pyproject_file.write_text(code)
231 logger.success("Created pyproject.toml")
233 # Create README.md if it doesn't exist
234 # Every project should have a README
235 readme_file = target / "README.md"
236 if not readme_file.exists():
237 logger.info("Creating README.md")
238 readme_file.touch()
239 logger.success("Created README.md")
241 # Validate the template file to ensure it's correct
242 # This will catch any issues early
243 logger.debug("Validating template configuration")
244 return validate(target)