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
« 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.
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**:
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.
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"""
19from __future__ import annotations
21import sys
22from pathlib import Path
23from typing import Any
25sys.path.insert(0, str(Path(__file__).resolve().parent))
26from _validate_log import Log # noqa: E402
27from _validate_structure import VALIDATORS # noqa: E402
29# Hosts the template may target; mirrors rhiza.models.template.GitHost.
30GIT_HOSTS = ("github", "gitlab")
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
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
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")
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"))
79 profiles_valid = validate_profiles_field(log, config)
80 if profiles_valid is False:
81 return False
82 has_profiles = profiles_valid is True
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
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
99 _report_mode(log, config, profiles=has_profiles, templates=has_templates)
100 return True
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
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
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
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
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}")
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}")
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}")
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}")
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}")
230def validate_optional_fields(log: Log, config: dict[str, Any]) -> None:
231 """Run the non-fatal optional-field checks.
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)