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

1#!/usr/bin/env python3 

2"""Shared helper for loading a YAML file into a top-level mapping. 

3 

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

14 

15from __future__ import annotations 

16 

17from dataclasses import dataclass 

18from enum import Enum 

19from pathlib import Path 

20from typing import Any 

21 

22import yaml 

23 

24 

25class YamlError(Enum): 

26 """Why a YAML file could not be loaded into a mapping.""" 

27 

28 NOT_FOUND = "not_found" 

29 INVALID = "invalid" 

30 EMPTY = "empty" 

31 NOT_MAPPING = "not_mapping" 

32 

33 

34@dataclass(frozen=True) 

35class YamlFailure: 

36 """A failed :func:`load_yaml_mapping`. 

37 

38 ``kind`` is the failure mode; ``detail`` carries the parser exception text 

39 when ``kind`` is :attr:`YamlError.INVALID` (empty otherwise). 

40 """ 

41 

42 kind: YamlError 

43 detail: str = "" 

44 

45 

46def load_yaml_mapping(path: Path) -> dict[Any, Any] | YamlFailure: 

47 """Load *path* and return its top-level YAML mapping. 

48 

49 Args: 

50 path: File to read. 

51 

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