Coverage for src/rhiza_hooks/_yaml.py: 100%
28 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-07-30 04:36 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-07-30 04:36 +0000
1#!/usr/bin/env python3
2"""Shared helper for loading a YAML file into a top-level mapping.
4Every rhiza-hooks entry point that reads a YAML config — the template-bundles
5loader (:mod:`rhiza_hooks._bundles_fetch`), the ``.rhiza/template.yml`` reader
6(:mod:`rhiza_hooks._bundles_config`), and the rhiza-config checker
7(:mod:`rhiza_hooks.check_rhiza_config`) — shares the same open →
8``yaml.safe_load`` → "is it a non-empty mapping?" sequence. Only the wording of
9their error messages differs. This module centralises that sequence and reports
10the failure mode as a small enum so each caller phrases its own message (or,
11like :func:`rhiza_hooks._bundles_config._get_config_data`, ignores the reason
12and simply treats any failure as "absent").
13"""
15from __future__ import annotations
17from dataclasses import dataclass
18from enum import Enum
19from pathlib import Path
20from typing import Any
22import yaml
25class YamlError(Enum):
26 """Why a YAML file could not be loaded into a mapping."""
28 NOT_FOUND = "not_found"
29 INVALID = "invalid"
30 EMPTY = "empty"
31 NOT_MAPPING = "not_mapping"
34@dataclass(frozen=True)
35class YamlFailure:
36 """A failed :func:`load_yaml_mapping`.
38 ``kind`` is the failure mode; ``detail`` carries the parser exception text
39 when ``kind`` is :attr:`YamlError.INVALID` (empty otherwise).
40 """
42 kind: YamlError
43 detail: str = ""
46def load_yaml_mapping(path: Path) -> dict[Any, Any] | YamlFailure:
47 """Load *path* and return its top-level YAML mapping.
49 Args:
50 path: File to read.
52 Returns:
53 The parsed mapping on success, or a :class:`YamlFailure` describing why
54 the file is missing, unreadable, invalid, empty, or not a mapping.
55 Callers ``isinstance``-check the result, which narrows both branches for
56 the type checker.
57 """
58 if not path.exists():
59 return YamlFailure(YamlError.NOT_FOUND)
60 try:
61 with path.open(encoding="utf-8") as f:
62 data = yaml.safe_load(f)
63 except (yaml.YAMLError, UnicodeDecodeError, ValueError, OverflowError) as exc:
64 # PyYAML's scanner raises bare ``ValueError``/``OverflowError`` — not
65 # ``yaml.YAMLError`` — on some malformed escapes, e.g. a ``"\\U…"`` flow
66 # scalar whose codepoint overflows ``chr()``. Treat those as invalid
67 # input rather than letting them escape as an uncaught crash.
68 return YamlFailure(YamlError.INVALID, str(exc))
69 if data is None:
70 return YamlFailure(YamlError.EMPTY)
71 if not isinstance(data, dict):
72 return YamlFailure(YamlError.NOT_MAPPING)
73 return data