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

55 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-15 14:46 +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, has_drive_letter # 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 # The Windows spellings are part of that set, not an afterthought: without them 

62 # `C:/Users/dev/template` matched none of these prefixes and was pasted onto the 

63 # GitHub base, so the sync tried to clone `https://github.com/C:/Users/... .git`. 

64 if ( 

65 "://" in self.repository 

66 or self.repository.startswith(("/", "./", "../", "\\", ".\\", "..\\")) 

67 or has_drive_letter(self.repository) 

68 ): 

69 return self.repository 

70 if self.host == "github": 

71 return f"https://github.com/{self.repository}.git" 

72 if self.host == "gitlab": 

73 return f"https://gitlab.com/{self.repository}.git" 

74 raise SyncError(f"Unsupported template-host: {self.host}. Must be 'github' or 'gitlab'.") 

75 

76 

77# Never delivered, whatever the config says: this is the consumer's own pointer at the 

78# template, and a template that ships a copy of one would otherwise overwrite it and 

79# retarget the repo. The deletion side of the same rule is `_PROTECTED` in `_rhiza_lock`. 

80_ALWAYS_EXCLUDED = ".rhiza/template.yml" 

81 

82 

83def normalise_excludes(entries: list[str]) -> set[str]: 

84 """Normalise configured ``exclude:`` entries into comparable **destination** paths. 

85 

86 `exclude:` is written in destination paths, because that is the only form a consumer 

87 knows — it is where the file lands in their repo. So the entries are taken as given 

88 and merely tidied: separators forward-slashed, a leading ``./`` and a trailing ``/`` 

89 dropped, blanks discarded. 

90 

91 Nothing here consults the template clone. Resolving these against the clone is what 

92 made bundle-sourced exclusions vanish: a destination path need not exist at the clone 

93 root at all — under a sparse checkout of ``bundles/…`` it usually does not — and an 

94 entry that failed to resolve was dropped silently rather than honoured. 

95 

96 The repo's own pointer at the template is always in the set, whatever the config says: 

97 

98 >>> sorted(normalise_excludes(["docs/", "./Makefile", " "])) 

99 ['.rhiza/template.yml', 'Makefile', 'docs'] 

100 """ 

101 result = { 

102 cleaned 

103 for cleaned in ( 

104 entry.strip().replace("\\", "/").removeprefix("./").rstrip("/") for entry in entries 

105 ) 

106 if cleaned 

107 } 

108 result.add(_ALWAYS_EXCLUDED) 

109 return result 

110 

111 

112def is_excluded(dest: str, excludes: set[str]) -> bool: 

113 """Whether the destination path *dest* is excluded outright or sits under an excluded dir. 

114 

115 The directory case has to be matched by prefix rather than by expanding the directory 

116 into its files, which is what :func:`normalise_excludes` used to do against the clone. 

117 Once matching moves to destination paths there is no directory on disk to expand. 

118 

119 The prefix carries the separator, so a directory covers what is under it without a 

120 sibling whose name merely starts the same way being swept in: 

121 

122 >>> excludes = normalise_excludes(["docs"]) 

123 >>> is_excluded("docs", excludes), is_excluded("docs/index.md", excludes) 

124 (True, True) 

125 >>> is_excluded("docsite/index.md", excludes) 

126 False 

127 """ 

128 return dest in excludes or any(dest.startswith(f"{entry}/") for entry in excludes) 

129 

130 

131def load_template(target: Path, template_file: Path) -> Template: 

132 """Load and validate the template config, raising :class:`SyncError` on any problem.""" 

133 if not template_file.exists(): 

134 raise SyncError(f"No template.yml found at {template_file}") 

135 try: 

136 config = load_yaml(template_file) 

137 except (OSError, ValueError) as exc: 

138 raise SyncError(f"Could not read {template_file}: {exc}") from exc 

139 

140 template = Template.from_config(config) 

141 if not template.repository: 

142 raise SyncError("template-repository is required in template.yml") 

143 if not template.templates and not template.include and not template.profiles: 

144 raise SyncError("template.yml must set at least one of: templates, profiles, include") 

145 return template 

146 

147 

148# --------------------------------------------------------------------------- 

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

150# ---------------------------------------------------------------------------