Coverage for src / rhiza / models / _base.py: 100%

30 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-02 07:04 +0000

1"""Base abstractions for YAML-serializable Rhiza models. 

2 

3This module defines the :class:`YamlSerializable` :class:`~typing.Protocol` that all 

4YAML-capable Rhiza model classes satisfy, plus the :func:`load_model` generic 

5helper that can load any such model from a file path. 

6 

7Example usage:: 

8 

9 from rhiza.models._base import load_model 

10 from rhiza.models.template import RhizaTemplate 

11 

12 template = load_model(RhizaTemplate, Path(".rhiza/template.yml")) 

13""" 

14 

15from pathlib import Path 

16from typing import Any, Protocol, Self, TypeVar, runtime_checkable 

17 

18import yaml 

19 

20 

21@runtime_checkable 

22class YamlSerializable(Protocol): 

23 """Structural protocol for Rhiza models with YAML round-trip support. 

24 

25 Any class that implements ``from_yaml`` and ``to_yaml`` with these 

26 signatures automatically satisfies this protocol (structural typing). 

27 

28 Implementors 

29 ------------ 

30 - :class:`rhiza.models.template.RhizaTemplate` 

31 - :class:`rhiza.models.lock.TemplateLock` 

32 - :class:`rhiza.models.bundle.RhizaBundles` (read-only: does not implement ``to_yaml``) 

33 """ 

34 

35 @classmethod 

36 def from_yaml(cls, file_path: Path) -> Self: 

37 """Load the model from a YAML file. 

38 

39 Args: 

40 file_path: Path to the YAML file to load. 

41 

42 Returns: 

43 A new instance populated from the file. 

44 

45 Raises: 

46 FileNotFoundError: If *file_path* does not exist. 

47 yaml.YAMLError: If the file contains invalid YAML. 

48 ValueError: If the file content is not recognised. 

49 """ 

50 return cls.from_config(read_yaml(file_path)) 

51 

52 @classmethod 

53 def from_config(cls, config: dict[str, Any]) -> Self: 

54 """Create a model instance from a configuration dictionary. 

55 

56 Args: 

57 config: Dictionary containing model configuration. 

58 

59 Returns: 

60 A new instance populated from the dictionary. 

61 """ 

62 ... 

63 

64 @property 

65 def config(self) -> dict[str, Any]: 

66 """Return the model's current state as a configuration dictionary.""" 

67 ... 

68 

69 def to_yaml(self, file_path: Path) -> None: 

70 """Save the model to a YAML file. 

71 

72 Args: 

73 file_path: Destination path. Parent directories are created 

74 automatically if they do not exist. 

75 """ 

76 file_path.parent.mkdir(parents=True, exist_ok=True) 

77 with open(file_path, "w", encoding="utf-8") as f: 

78 yaml.dump(self.config, f, default_flow_style=False, sort_keys=False) 

79 

80 

81_T = TypeVar("_T") 

82 

83 

84def read_yaml(path: Path) -> dict[str, Any]: 

85 """Open *path*, parse YAML, and return the config dict. 

86 

87 Args: 

88 path: Path to the YAML file to load. 

89 

90 Returns: 

91 Parsed YAML content as a dictionary. 

92 

93 Raises: 

94 FileNotFoundError: If *path* does not exist. 

95 yaml.YAMLError: If the file contains invalid YAML. 

96 ValueError: If the file is empty or null. 

97 TypeError: If the file does not contain a YAML mapping. 

98 """ 

99 with open(path, encoding="utf-8") as f: 

100 data = yaml.safe_load(f) 

101 if data is None: 

102 raise ValueError(f"{path.name} is empty") # noqa: TRY003 

103 if not isinstance(data, dict): 

104 raise TypeError(f"{path.name} does not contain a YAML mapping") # noqa: TRY003 

105 return data 

106 

107 

108def load_model(cls: type[_T], path: Path) -> _T: 

109 """Load a YAML-serializable model from *path*. 

110 

111 This is a thin generic wrapper around ``cls.from_yaml(path)`` that 

112 preserves the concrete return type so callers do not need a cast. 

113 

114 Args: 

115 cls: A class that exposes a ``from_yaml(Path)`` classmethod, 

116 such as :class:`~rhiza.models.template.RhizaTemplate`, 

117 :class:`~rhiza.models.lock.TemplateLock`, or 

118 :class:`~rhiza.models.bundle.RhizaBundles`. 

119 path: Path to the YAML file to load. 

120 

121 Returns: 

122 An instance of *cls* populated from *path*. 

123 

124 Raises: 

125 TypeError: If *cls* does not implement ``from_yaml``. 

126 

127 Example:: 

128 

129 from rhiza.models._base import load_model 

130 from rhiza.models.lock import TemplateLock 

131 

132 lock = load_model(TemplateLock, Path(".rhiza/template.lock")) 

133 """ 

134 from_config = getattr(cls, "from_config", None) 

135 if not callable(from_config): 

136 raise TypeError(f"{cls.__name__} does not implement from_config") # noqa: TRY003 

137 return cls.from_config(read_yaml(path)) # type: ignore[attr-defined]