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

1"""Parse and validate `.rhiza/template.yml` — the sync's input contract. 

2 

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

7 

8from __future__ import annotations 

9 

10import sys 

11from dataclasses import dataclass, field 

12from pathlib import Path 

13from typing import Any 

14 

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 

18 

19_DEFAULT_BUNDLES_PATH = ".rhiza/template-bundles.yml" 

20 

21 

22@dataclass(frozen=True) 

23class Template: 

24 """The parsed `.rhiza/template.yml` fields sync needs.""" 

25 

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 

34 

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 ) 

50 

51 @property 

52 def git_url(self) -> str: 

53 """Return the HTTPS clone URL for the configured repository and host. 

54 

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

68 

69 

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 

78 

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 

85 

86 

87# --------------------------------------------------------------------------- 

88# Bundle resolution (profiles/templates -> file paths) 

89# ---------------------------------------------------------------------------