mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 10:59:59 +00:00
The marker names the reason a role has no upstream homepage or demo video, and
it sat in meta/main.yml because a comment-only info.yml parses to an empty
document, which the schema lint rejected outright. So the exemption lived one
file away from the two fields it explains, and a reader looking for the video
found neither the field nor the reason it was missing.
The schema lint now accepts an empty document exactly when it carries the
marker, and that is what allows the move. The media lint reads the marker from
info.yml rather than main.yml, and its failure message names that same file, so
the fix it suggests lands where the absent field would have gone.
A hundred and two roles follow. Every occurrence was a standalone comment on
the second line of main.yml, directly under the document start, so the move is
a line delete and an insert at the same offset. Seventy-three roles already had
an info.yml and gain the line; twenty-nine get one whose whole content is the
document start and the marker. All hundred and two reasons carry over verbatim,
including the thirty-five bespoke ones.
Nothing downstream sees a change. Of the four readers of meta/info.yml, three
coerce a null parse through `or {}` behind an isinstance guard, and
utils/cache/applications.py skips a falsy parse before assignment, so
applications.<role>.info stays absent for the twenty-nine exactly as it was
while the file did not exist.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
183 lines
6.1 KiB
Python
183 lines
6.1 KiB
Python
"""Lint guard: ``roles/<role>/meta/info.yml`` schema.
|
|
|
|
* the file is OPTIONAL (a role with no descriptive metadata does not
|
|
grow the file), and an otherwise empty one is legal only when it
|
|
carries ``# nocheck: info-media``, which is the exemption for a role
|
|
with no upstream homepage or demo video to name;
|
|
* file-root convention: the file's content IS the value of
|
|
``applications.<role>.info`` — there is NO wrapping ``info:`` key;
|
|
* allowed top-level keys: ``logo``, ``homepage``, ``video``, ``display``;
|
|
* ``logo`` is a mapping with at least a string ``class:`` field;
|
|
* ``homepage`` and ``video`` are non-empty strings;
|
|
* ``display`` is a bool;
|
|
* none of these four fields may reappear under
|
|
``meta/main.yml.galaxy_info``.
|
|
|
|
Caching: parsed YAML comes through the shared process-wide
|
|
``utils.cache.yaml.load_yaml_any`` cache, so multiple lint tests that
|
|
read the same ``meta/main.yml`` / ``meta/info.yml`` pay one parse.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import unittest
|
|
from typing import TYPE_CHECKING
|
|
|
|
import yaml as _yaml
|
|
|
|
from utils.annotations.suppress import is_suppressed_in_head
|
|
from utils.cache.files import PROJECT_ROOT, read_text
|
|
from utils.cache.yaml import load_yaml_any
|
|
from utils.roles.mapping import ROLE_FILE_META_INFO, ROLE_FILE_META_MAIN
|
|
|
|
if TYPE_CHECKING:
|
|
from pathlib import Path
|
|
|
|
ROLES_DIR = PROJECT_ROOT / "roles"
|
|
|
|
_ALLOWED_INFO_KEYS: frozenset[str] = frozenset({"logo", "homepage", "video", "display"})
|
|
|
|
_INFO_MEDIA_RULE = "info-media"
|
|
|
|
_FORBIDDEN_GALAXY_INFO_KEYS: frozenset[str] = frozenset(
|
|
{"logo", "homepage", "video", "display"}
|
|
)
|
|
|
|
|
|
def _meta_main_paths() -> list[Path]:
|
|
if not ROLES_DIR.is_dir():
|
|
return []
|
|
return sorted(
|
|
role_dir / ROLE_FILE_META_MAIN
|
|
for role_dir in ROLES_DIR.iterdir()
|
|
if role_dir.is_dir() and (role_dir / ROLE_FILE_META_MAIN).is_file()
|
|
)
|
|
|
|
|
|
def _meta_info_paths() -> list[Path]:
|
|
if not ROLES_DIR.is_dir():
|
|
return []
|
|
return sorted(
|
|
role_dir / ROLE_FILE_META_INFO
|
|
for role_dir in ROLES_DIR.iterdir()
|
|
if role_dir.is_dir() and (role_dir / ROLE_FILE_META_INFO).is_file()
|
|
)
|
|
|
|
|
|
def _validate_meta_info(path: Path) -> list[str]:
|
|
try:
|
|
parsed = load_yaml_any(str(path), default_if_missing={})
|
|
except _yaml.YAMLError as exc:
|
|
return [f"YAML parse error: {exc}"]
|
|
if parsed in (None, {}):
|
|
if is_suppressed_in_head(read_text(str(path)).splitlines(), _INFO_MEDIA_RULE):
|
|
return []
|
|
return ["file is empty (delete the file or add at least one allowed field)"]
|
|
if not isinstance(parsed, dict):
|
|
return [f"top-level must be a mapping; got {type(parsed).__name__}"]
|
|
|
|
problems: list[str] = []
|
|
|
|
if "info" in parsed and isinstance(parsed.get("info"), dict):
|
|
problems.append(
|
|
"file uses an `info:` wrapper key — mandates the file-root "
|
|
"convention (the file's content IS applications.<role>.info)"
|
|
)
|
|
|
|
unknown = sorted(set(parsed.keys()) - _ALLOWED_INFO_KEYS)
|
|
if unknown:
|
|
problems.append(
|
|
"non-allowlisted top-level key(s): "
|
|
+ ", ".join(unknown)
|
|
+ f"; allowed: {sorted(_ALLOWED_INFO_KEYS)}"
|
|
)
|
|
|
|
if "logo" in parsed:
|
|
logo = parsed["logo"]
|
|
if not isinstance(logo, dict):
|
|
problems.append("`logo` must be a mapping")
|
|
elif "class" in logo and not isinstance(logo["class"], str):
|
|
problems.append("`logo.class`, when present, must be a string")
|
|
|
|
for str_field in ("homepage", "video"):
|
|
if str_field in parsed:
|
|
value = parsed[str_field]
|
|
if not isinstance(value, str) or not value.strip():
|
|
problems.append(f"`{str_field}` must be a non-empty string")
|
|
|
|
if "display" in parsed and not isinstance(parsed["display"], bool):
|
|
problems.append("`display` must be a bool (true/false)")
|
|
|
|
return problems
|
|
|
|
|
|
def _validate_meta_main_no_forbidden(path: Path) -> list[str]:
|
|
try:
|
|
parsed = load_yaml_any(str(path), default_if_missing={})
|
|
except _yaml.YAMLError:
|
|
return []
|
|
if not isinstance(parsed, dict):
|
|
return []
|
|
galaxy_info = parsed.get("galaxy_info")
|
|
if not isinstance(galaxy_info, dict):
|
|
return []
|
|
found = sorted(_FORBIDDEN_GALAXY_INFO_KEYS.intersection(galaxy_info.keys()))
|
|
if not found:
|
|
return []
|
|
return [
|
|
"Forbidden key(s) under galaxy_info: "
|
|
+ ", ".join(found)
|
|
+ " (move them to meta/info.yml)"
|
|
]
|
|
|
|
|
|
class TestMetaInfoShape(unittest.TestCase):
|
|
"""Enforce the meta/info.yml schema."""
|
|
|
|
def test_meta_info_files_are_well_shaped(self) -> None:
|
|
offenders: dict[Path, list[str]] = {}
|
|
for path in _meta_info_paths():
|
|
problems = _validate_meta_info(path)
|
|
if problems:
|
|
offenders[path] = problems
|
|
|
|
if not offenders:
|
|
return
|
|
|
|
rel = lambda p: p.relative_to(PROJECT_ROOT) # noqa: E731
|
|
lines = [f"{len(offenders)} meta/info.yml file(s) violate:"]
|
|
for path, problems in sorted(offenders.items()):
|
|
lines.append(f" - {rel(path)}:")
|
|
lines.extend(f" * {problem}" for problem in problems)
|
|
self.fail("\n".join(lines))
|
|
|
|
|
|
class TestMetaMainHasNoMigratedFields(unittest.TestCase):
|
|
"""galaxy_info MUST NOT carry the four fields after."""
|
|
|
|
def test_no_forbidden_keys_in_galaxy_info(self) -> None:
|
|
offenders: dict[Path, list[str]] = {}
|
|
for path in _meta_main_paths():
|
|
problems = _validate_meta_main_no_forbidden(path)
|
|
if problems:
|
|
offenders[path] = problems
|
|
|
|
if not offenders:
|
|
return
|
|
|
|
rel = lambda p: p.relative_to(PROJECT_ROOT) # noqa: E731
|
|
lines = [
|
|
(
|
|
f"{len(offenders)} meta/main.yml file(s) still carry "
|
|
f"forbidden keys under galaxy_info:"
|
|
)
|
|
]
|
|
for path, problems in sorted(offenders.items()):
|
|
lines.append(f" - {rel(path)}:")
|
|
lines.extend(f" * {problem}" for problem in problems)
|
|
self.fail("\n".join(lines))
|
|
|
|
|
|
if __name__ == "__main__": # pragma: no cover
|
|
unittest.main()
|