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

1"""Command to initialize or validate .github/rhiza/template.yml. 

2 

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""" 

7 

8import importlib.resources 

9import keyword 

10import re 

11import sys 

12from pathlib import Path 

13 

14import typer 

15from jinja2 import Template 

16from loguru import logger 

17 

18from rhiza.commands.validate import validate 

19from rhiza.models import RhizaTemplate 

20 

21 

22def _normalize_package_name(name: str) -> str: 

23 """Normalize a string into a valid Python package name. 

24 

25 Args: 

26 name: The input string (e.g., project name). 

27 

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) 

33 

34 # Ensure it doesn't start with a number 

35 if name[0].isdigit(): 

36 name = f"_{name}" 

37 

38 # Ensure it's not a Python keyword 

39 if keyword.iskeyword(name): 

40 name = f"{name}_" 

41 

42 return name 

43 

44 

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. 

53 

54 Creates a default .github/rhiza/template.yml file if it doesn't exist, 

55 or validates an existing one. 

56 

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. 

64 

65 Returns: 

66 bool: True if validation passes, False otherwise. 

67 """ 

68 # Convert to absolute path to avoid surprises 

69 target = target.resolve() 

70 

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'") 

77 

78 logger.info(f"Initializing Rhiza configuration in: {target}") 

79 

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) 

85 

86 # Define the template file path 

87 template_file = rhiza_dir / "template.yml" 

88 

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") 

93 

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() 

104 

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") 

117 

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 ] 

150 

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 ) 

160 

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) 

164 

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""") 

171 

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 

176 

177 if package_name is None: 

178 package_name = _normalize_package_name(project_name) 

179 

180 logger.debug(f"Project name: {project_name}") 

181 logger.debug(f"Package name: {package_name}") 

182 

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) 

188 

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() 

193 

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) 

200 

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() 

205 

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}") 

212 

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() 

219 

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") 

232 

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") 

240 

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)