Coverage for src / rhiza / commands / validate.py: 100%

129 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2025-12-29 01:59 +0000

1"""Command for validating Rhiza template configuration. 

2 

3This module provides functionality to validate template.yml files in the 

4.rhiza/template.yml location (new standard location after migration). 

5""" 

6 

7from pathlib import Path 

8 

9import yaml 

10from loguru import logger 

11 

12 

13def validate(target: Path) -> bool: 

14 """Validate template.yml configuration in the target repository. 

15 

16 Performs authoritative validation of the template configuration: 

17 - Checks if target is a git repository 

18 - Checks for standard project structure (src and tests folders) 

19 - Checks for pyproject.toml (required) 

20 - Checks if template.yml exists 

21 - Validates YAML syntax 

22 - Validates required fields 

23 - Validates field values are appropriate 

24 

25 Args: 

26 target: Path to the target Git repository directory. 

27 

28 Returns: 

29 True if validation passes, False otherwise. 

30 """ 

31 # Convert to absolute path to avoid path resolution issues 

32 target = target.resolve() 

33 

34 # Check if target is a git repository by looking for .git directory 

35 # Rhiza only works with git repositories 

36 if not (target / ".git").is_dir(): 

37 logger.error(f"Target directory is not a git repository: {target}") 

38 logger.error("Initialize a git repository with 'git init' first") 

39 return False 

40 

41 logger.info(f"Validating template configuration in: {target}") 

42 

43 # Check for standard project structure (src and tests folders) 

44 logger.debug("Validating project structure") 

45 src_dir = target / "src" 

46 tests_dir = target / "tests" 

47 

48 if not src_dir.exists(): 

49 logger.warning(f"Standard 'src' folder not found: {src_dir}") 

50 logger.warning("Consider creating a 'src' directory for source code") 

51 else: 

52 logger.success(f"'src' folder exists: {src_dir}") 

53 

54 if not tests_dir.exists(): 

55 logger.warning(f"Standard 'tests' folder not found: {tests_dir}") 

56 logger.warning("Consider creating a 'tests' directory for test files") 

57 else: 

58 logger.success(f"'tests' folder exists: {tests_dir}") 

59 

60 # Check for pyproject.toml - this is always required 

61 logger.debug("Validating pyproject.toml") 

62 pyproject_file = target / "pyproject.toml" 

63 

64 if not pyproject_file.exists(): 

65 logger.error(f"pyproject.toml not found: {pyproject_file}") 

66 logger.error("pyproject.toml is required for Python projects") 

67 logger.info("Run 'rhiza init' to create a default pyproject.toml") 

68 return False 

69 else: 

70 logger.success(f"pyproject.toml exists: {pyproject_file}") 

71 

72 # Check for template.yml in new location only 

73 template_file = target / ".rhiza" / "template.yml" 

74 

75 if not template_file.exists(): 

76 logger.error(f"No template file found at: {template_file.relative_to(target)}") 

77 logger.error("The template configuration must be in the .rhiza folder.") 

78 logger.info("") 

79 logger.info("To fix this:") 

80 logger.info(" • If you're starting fresh, run: rhiza init") 

81 logger.info(" • If you have an existing configuration, run: rhiza migrate") 

82 logger.info("") 

83 logger.info("The 'rhiza migrate' command will move your configuration from") 

84 logger.info(" .github/rhiza/template.yml → .rhiza/template.yml") 

85 return False 

86 

87 logger.success(f"Template file exists: {template_file.relative_to(target)}") 

88 

89 # Validate YAML syntax by attempting to parse the file 

90 logger.debug(f"Parsing YAML file: {template_file}") 

91 try: 

92 with open(template_file) as f: 

93 config = yaml.safe_load(f) 

94 except yaml.YAMLError as e: 

95 logger.error(f"Invalid YAML syntax in template.yml: {e}") 

96 logger.error("Fix the YAML syntax errors and try again") 

97 return False 

98 

99 # Check if the file is completely empty 

100 if config is None: 

101 logger.error("template.yml is empty") 

102 logger.error("Add configuration to template.yml or run 'rhiza init' to generate defaults") 

103 return False 

104 

105 logger.success("YAML syntax is valid") 

106 

107 # Validate required fields exist and have correct types 

108 # template-repository: Must be a string in 'owner/repo' format 

109 # include: Must be a non-empty list of paths 

110 logger.debug("Validating required fields") 

111 required_fields = { 

112 "template-repository": str, 

113 "include": list, 

114 } 

115 

116 validation_passed = True 

117 

118 # Check each required field 

119 for field, expected_type in required_fields.items(): 

120 if field not in config: 

121 logger.error(f"Missing required field: {field}") 

122 logger.error(f"Add '{field}' to your template.yml") 

123 validation_passed = False 

124 elif not isinstance(config[field], expected_type): 

125 logger.error( 

126 f"Field '{field}' must be of type {expected_type.__name__}, got {type(config[field]).__name__}" 

127 ) 

128 logger.error(f"Fix the type of '{field}' in template.yml") 

129 validation_passed = False 

130 else: 

131 logger.success(f"Field '{field}' is present and valid") 

132 

133 # Validate template-repository format 

134 # Must be in 'owner/repo' format (e.g., 'jebel-quant/rhiza') 

135 logger.debug("Validating template-repository format") 

136 if "template-repository" in config: 

137 repo = config["template-repository"] 

138 if not isinstance(repo, str): 

139 logger.error(f"template-repository must be a string, got {type(repo).__name__}") 

140 logger.error("Example: 'owner/repository'") 

141 validation_passed = False 

142 elif "/" not in repo: 

143 logger.error(f"template-repository must be in format 'owner/repo', got: {repo}") 

144 logger.error("Example: 'jebel-quant/rhiza'") 

145 validation_passed = False 

146 else: 

147 logger.success(f"template-repository format is valid: {repo}") 

148 

149 # Validate include paths 

150 # Must be a non-empty list of strings 

151 logger.debug("Validating include paths") 

152 if "include" in config: 

153 include = config["include"] 

154 if not isinstance(include, list): 

155 logger.error(f"include must be a list, got {type(include).__name__}") 

156 logger.error("Example: include: ['.github', '.gitignore']") 

157 validation_passed = False 

158 elif len(include) == 0: 

159 logger.error("include list cannot be empty") 

160 logger.error("Add at least one path to materialize") 

161 validation_passed = False 

162 else: 

163 logger.success(f"include list has {len(include)} path(s)") 

164 # Log each included path for transparency 

165 for path in include: 

166 if not isinstance(path, str): 

167 logger.warning(f"include path should be a string, got {type(path).__name__}: {path}") 

168 else: 

169 logger.info(f" - {path}") 

170 

171 # Validate optional fields if present 

172 # template-branch: Branch name in the template repository 

173 logger.debug("Validating optional fields") 

174 if "template-branch" in config: 

175 branch = config["template-branch"] 

176 if not isinstance(branch, str): 

177 logger.warning(f"template-branch should be a string, got {type(branch).__name__}: {branch}") 

178 logger.warning("Example: 'main' or 'develop'") 

179 else: 

180 logger.success(f"template-branch is valid: {branch}") 

181 

182 # template-host: Git hosting platform (github or gitlab) 

183 if "template-host" in config: 

184 host = config["template-host"] 

185 if not isinstance(host, str): 

186 logger.warning(f"template-host should be a string, got {type(host).__name__}: {host}") 

187 logger.warning("Must be 'github' or 'gitlab'") 

188 elif host not in ("github", "gitlab"): 

189 logger.warning(f"template-host should be 'github' or 'gitlab', got: {host}") 

190 logger.warning("Other hosts are not currently supported") 

191 else: 

192 logger.success(f"template-host is valid: {host}") 

193 

194 # exclude: Optional list of paths to exclude from materialization 

195 if "exclude" in config: 

196 exclude = config["exclude"] 

197 if not isinstance(exclude, list): 

198 logger.warning(f"exclude should be a list, got {type(exclude).__name__}") 

199 logger.warning("Example: exclude: ['.github/workflows/ci.yml']") 

200 else: 

201 logger.success(f"exclude list has {len(exclude)} path(s)") 

202 # Log each excluded path for transparency 

203 for path in exclude: 

204 if not isinstance(path, str): 

205 logger.warning(f"exclude path should be a string, got {type(path).__name__}: {path}") 

206 else: 

207 logger.info(f" - {path}") 

208 

209 # Final verdict on validation 

210 logger.debug("Validation complete, determining final result") 

211 if validation_passed: 

212 logger.success("✓ Validation passed: template.yml is valid") 

213 return True 

214 else: 

215 logger.error("✗ Validation failed: template.yml has errors") 

216 logger.error("Fix the errors above and run 'rhiza validate' again") 

217 return False