Coverage for plugin/scripts/_validate_fields.py: 100%

160 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-15 14:46 +0000

1#!/usr/bin/env python3 

2"""Is `template.yml` well-formed? — `validate.py`'s configuration half. 

3 

4Split from the structure checks because the two answer different questions and fail 

5differently. Structure asks about the repo on disk; this asks about the config the user 

6wrote, where the division that matters is **fatal versus advisory**: 

7 

8- :func:`validate_configuration_mode`, :func:`validate_required_fields`, 

9 :func:`validate_repository_format` and :func:`validate_string_list` return a verdict. 

10 Without a selection mode or a `repository`, the sync has nothing to do. 

11- :func:`validate_optional_fields` returns nothing at all. A malformed `template-host` is 

12 worth saying out loud, but it must not stop a sync that would otherwise work. 

13 

14Every message pairs the fault with the fix. These are read by someone whose sync just 

15refused to run, so "must be in format 'owner/repo'" is followed by an example rather than 

16left as a specification. 

17""" 

18 

19from __future__ import annotations 

20 

21import sys 

22from pathlib import Path 

23from typing import Any 

24 

25sys.path.insert(0, str(Path(__file__).resolve().parent)) 

26from _validate_log import Log # noqa: E402 

27from _validate_structure import VALIDATORS # noqa: E402 

28 

29# Hosts the template may target; mirrors rhiza.models.template.GitHost. 

30GIT_HOSTS = ("github", "gitlab") 

31 

32 

33def _repo_field(config: dict[str, Any]) -> str | None: 

34 """Return whichever repository key *config* uses, or None when it has neither.""" 

35 for field in ("template-repository", "repository"): 

36 if field in config: 

37 return field 

38 return None 

39 

40 

41def validate_profiles_field(log: Log, config: dict[str, Any]) -> bool | None: 

42 """None when absent, True/False when present and valid/invalid.""" 

43 if "profiles" not in config: 

44 return None 

45 profiles = config["profiles"] 

46 if not isinstance(profiles, list): 

47 log.error(f"profiles must be a list, got {type(profiles).__name__}") 

48 log.error("Example: profiles: [github-project]") 

49 return False 

50 if not profiles: 

51 log.error("profiles list cannot be empty") 

52 log.error("Example: profiles: [github-project]") 

53 return False 

54 for p in profiles: 

55 if not isinstance(p, str) or not p.strip(): 

56 log.error(f"Each entry in profiles must be a non-empty string, got: {p!r}") 

57 return False 

58 return True 

59 

60 

61def _report_mode(log: Log, config: dict[str, Any], *, profiles: bool, templates: bool) -> None: 

62 """Announce which selection mode the config resolved to.""" 

63 if profiles: 

64 log.success(f"Using profile mode (profiles: {config['profiles']})") 

65 elif templates and bool(config.get("include")): 

66 log.success("Using hybrid mode (templates + include)") 

67 elif templates: 

68 log.success("Using template-based mode") 

69 else: 

70 log.success("Using path-based mode") 

71 

72 

73def validate_configuration_mode(log: Log, config: dict[str, Any]) -> bool: 

74 """Validate the profiles/templates/include selection mode.""" 

75 log.debug("Validating configuration mode") 

76 has_templates = bool(config.get("templates")) 

77 has_include = bool(config.get("include")) 

78 

79 profiles_valid = validate_profiles_field(log, config) 

80 if profiles_valid is False: 

81 return False 

82 has_profiles = profiles_valid is True 

83 

84 if "bundles" in config: 

85 log.error("Field 'bundles' has been renamed to 'templates'") 

86 log.error("Update your .rhiza/template.yml: bundles: [...] → templates: [...]") 

87 return False 

88 

89 if not has_profiles and not has_templates and not has_include: 

90 log.error( 

91 "Must specify at least one of 'profiles', 'templates', or 'include' in template.yml" 

92 ) 

93 log.error(" • Profile-based: profiles: [github-project]") 

94 log.error(" • Template-based: templates: [core, tests, github]") 

95 log.error(" • Path-based: include: [.rhiza, .github, ...]") 

96 log.error(" • Hybrid: specify both templates and include") 

97 return False 

98 

99 _report_mode(log, config, profiles=has_profiles, templates=has_templates) 

100 return True 

101 

102 

103def validate_required_fields(log: Log, config: dict[str, Any]) -> bool: 

104 """Require a 'template-repository' or 'repository' string field.""" 

105 log.debug("Validating required fields") 

106 repo_field = _repo_field(config) 

107 if repo_field is None: 

108 log.error("Missing required field: 'template-repository' or 'repository'") 

109 log.error("Add 'template-repository' or 'repository' to your template.yml") 

110 return False 

111 

112 repo_value = config[repo_field] 

113 if not isinstance(repo_value, str): 

114 log.error(f"Field '{repo_field}' must be of type str, got {type(repo_value).__name__}") 

115 log.error(f"Fix the type of '{repo_field}' in template.yml") 

116 return False 

117 log.success(f"Field '{repo_field}' is present and valid") 

118 return True 

119 

120 

121def validate_repository_format(log: Log, config: dict[str, Any]) -> bool: 

122 """Check the repository field is in 'owner/repo' form.""" 

123 log.debug("Validating repository format") 

124 repo_field = _repo_field(config) 

125 if repo_field is None: 

126 return True # caught by validate_required_fields 

127 repo = config[repo_field] 

128 if not isinstance(repo, str): 

129 log.error(f"{repo_field} must be a string, got {type(repo).__name__}") 

130 log.error("Example: 'owner/repository'") 

131 return False 

132 if "/" not in repo: 

133 log.error(f"{repo_field} must be in format 'owner/repo', got: {repo}") 

134 log.error("Example: 'jebel-quant/rhiza'") 

135 return False 

136 log.success(f"{repo_field} format is valid: {repo}") 

137 return True 

138 

139 

140def validate_string_list(log: Log, config: dict[str, Any], field: str, example: str) -> bool: 

141 """Shared check for the `templates` / `include` list fields.""" 

142 log.debug(f"Validating {field} field") 

143 if field not in config: 

144 return True 

145 value = config[field] 

146 if not isinstance(value, list): 

147 log.error(f"{field} must be a list, got {type(value).__name__}") 

148 log.error(f"Example: {example}") 

149 return False 

150 if len(value) == 0: 

151 log.error(f"{field} list cannot be empty") 

152 log.error("Add at least one entry to materialize") 

153 return False 

154 log.success(f"{field} list has {len(value)} entr{'y' if len(value) == 1 else 'ies'}") 

155 _log_entries(log, field, value) 

156 return True 

157 

158 

159def _log_entries(log: Log, field: str, values: list[Any]) -> None: 

160 """Echo each entry of a list field, warning on any that is not a string.""" 

161 for item in values: 

162 if not isinstance(item, str): 

163 log.warning(f"{field} entry should be a string, got {type(item).__name__}: {item}") 

164 else: 

165 log.info(f" - {item}") 

166 

167 

168def _validate_branch_field(log: Log, config: dict[str, Any]) -> None: 

169 """Warn if the branch/ref field is present but not a string.""" 

170 branch_field = ( 

171 "template-branch" if "template-branch" in config else "ref" if "ref" in config else None 

172 ) 

173 if branch_field is None: 

174 return 

175 branch = config[branch_field] 

176 if not isinstance(branch, str): 

177 log.warning(f"{branch_field} should be a string, got {type(branch).__name__}: {branch}") 

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

179 else: 

180 log.success(f"{branch_field} is valid: {branch}") 

181 

182 

183def _validate_host_field(log: Log, config: dict[str, Any]) -> None: 

184 """Warn if template-host is non-string or an unsupported host.""" 

185 if "template-host" not in config: 

186 return 

187 host = config["template-host"] 

188 if not isinstance(host, str): 

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

190 log.warning("Must be 'github' or 'gitlab'") 

191 elif host not in GIT_HOSTS: 

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

193 log.warning("Other hosts are not currently supported") 

194 else: 

195 log.success(f"template-host is valid: {host}") 

196 

197 

198def _validate_language_field(log: Log, config: dict[str, Any]) -> None: 

199 """Warn if the language field is non-string or unrecognized.""" 

200 if "language" not in config: 

201 return 

202 language = config["language"] 

203 if not isinstance(language, str): 

204 log.warning(f"language should be a string, got {type(language).__name__}: {language}") 

205 log.warning("Example: 'python', 'go', 'rust'") 

206 elif language.lower() not in VALIDATORS: 

207 log.warning(f"language '{language}' is not recognized") 

208 log.warning(f"Supported languages: {', '.join(VALIDATORS)}") 

209 else: 

210 log.success(f"language is valid: {language}") 

211 

212 

213def _validate_exclude_field(log: Log, config: dict[str, Any]) -> None: 

214 """Warn if the exclude field is malformed.""" 

215 if "exclude" not in config: 

216 return 

217 exclude = config["exclude"] 

218 if not isinstance(exclude, list): 

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

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

221 return 

222 log.success(f"exclude list has {len(exclude)} path(s)") 

223 for path in exclude: 

224 if not isinstance(path, str): 

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

226 else: 

227 log.info(f" - {path}") 

228 

229 

230def validate_optional_fields(log: Log, config: dict[str, Any]) -> None: 

231 """Run the non-fatal optional-field checks. 

232 

233 Returns nothing on purpose: none of these can fail a validation. A malformed 

234 `template-host` deserves a warning, not a refusal to sync. 

235 """ 

236 log.debug("Validating optional fields") 

237 _validate_branch_field(log, config) 

238 _validate_host_field(log, config) 

239 _validate_language_field(log, config) 

240 _validate_exclude_field(log, config)