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
« 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.
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, has_drive_letter # 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 # 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'.")
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"
83def normalise_excludes(entries: list[str]) -> set[str]:
84 """Normalise configured ``exclude:`` entries into comparable **destination** paths.
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.
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.
96 The repo's own pointer at the template is always in the set, whatever the config says:
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
112def is_excluded(dest: str, excludes: set[str]) -> bool:
113 """Whether the destination path *dest* is excluded outright or sits under an excluded dir.
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.
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:
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)
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
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
148# ---------------------------------------------------------------------------
149# Bundle resolution (profiles/templates -> file paths)
150# ---------------------------------------------------------------------------