Coverage for scripts/_rhiza_template.py: 100%
48 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-31 06:18 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-31 06:18 +0000
1"""Parse and validate `.rhiza/template.yml` — the sync's input contract.
3Answers one question: which template repository, at which ref, with which
4profiles/bundles/includes. Nothing here touches git or the filesystem beyond reading
5that one file, so the whole module is testable with a string.
6"""
8from __future__ import annotations
10import sys
11from dataclasses import dataclass, field
12from pathlib import Path
13from typing import Any
15sys.path.insert(0, str(Path(__file__).resolve().parent))
16from _rhiza_common import SyncError # noqa: E402
17from _rhiza_yaml import as_list, load_yaml # noqa: E402
19_DEFAULT_BUNDLES_PATH = ".rhiza/template-bundles.yml"
22@dataclass(frozen=True)
23class Template:
24 """The parsed `.rhiza/template.yml` fields sync needs."""
26 repository: str
27 ref: str
28 host: str = "github"
29 include: list[str] = field(default_factory=list)
30 exclude: list[str] = field(default_factory=list)
31 templates: list[str] = field(default_factory=list)
32 profiles: list[str] = field(default_factory=list)
33 bundles_path: str = _DEFAULT_BUNDLES_PATH
35 @classmethod
36 def from_config(cls, config: dict[str, Any]) -> Template:
37 """Build a :class:`Template` from a parsed template.yml dict, honouring key aliases."""
38 repository = config.get("repository") or config.get("template-repository") or ""
39 ref = config.get("ref") or config.get("template-branch") or ""
40 return cls(
41 repository=str(repository),
42 ref=str(ref),
43 host=str(config.get("template-host", "github")),
44 include=as_list(config.get("include")),
45 exclude=as_list(config.get("exclude")),
46 templates=as_list(config.get("templates")),
47 profiles=as_list(config.get("profiles")),
48 bundles_path=str(config.get("template-bundles-path", _DEFAULT_BUNDLES_PATH)),
49 )
51 @property
52 def git_url(self) -> str:
53 """Return the HTTPS clone URL for the configured repository and host.
55 Raises:
56 SyncError: If the repository is unset or the host is unsupported.
57 """
58 if not self.repository:
59 raise SyncError("template-repository is not configured in template.yml")
60 # A full URL or local/file path (self-hosted or under test) is used verbatim.
61 if "://" in self.repository or self.repository.startswith(("/", "./", "../")):
62 return self.repository
63 if self.host == "github":
64 return f"https://github.com/{self.repository}.git"
65 if self.host == "gitlab":
66 return f"https://gitlab.com/{self.repository}.git"
67 raise SyncError(f"Unsupported template-host: {self.host}. Must be 'github' or 'gitlab'.")
70def load_template(target: Path, template_file: Path) -> Template:
71 """Load and validate the template config, raising :class:`SyncError` on any problem."""
72 if not template_file.exists():
73 raise SyncError(f"No template.yml found at {template_file}")
74 try:
75 config = load_yaml(template_file)
76 except (OSError, ValueError) as exc:
77 raise SyncError(f"Could not read {template_file}: {exc}") from exc
79 template = Template.from_config(config)
80 if not template.repository:
81 raise SyncError("template-repository is required in template.yml")
82 if not template.templates and not template.include and not template.profiles:
83 raise SyncError("template.yml must set at least one of: templates, profiles, include")
84 return template
87# ---------------------------------------------------------------------------
88# Bundle resolution (profiles/templates -> file paths)
89# ---------------------------------------------------------------------------