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
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-02 07:04 +0000
1"""Base abstractions for YAML-serializable Rhiza models.
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.
7Example usage::
9 from rhiza.models._base import load_model
10 from rhiza.models.template import RhizaTemplate
12 template = load_model(RhizaTemplate, Path(".rhiza/template.yml"))
13"""
15from pathlib import Path
16from typing import Any, Protocol, Self, TypeVar, runtime_checkable
18import yaml
21@runtime_checkable
22class YamlSerializable(Protocol):
23 """Structural protocol for Rhiza models with YAML round-trip support.
25 Any class that implements ``from_yaml`` and ``to_yaml`` with these
26 signatures automatically satisfies this protocol (structural typing).
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 """
35 @classmethod
36 def from_yaml(cls, file_path: Path) -> Self:
37 """Load the model from a YAML file.
39 Args:
40 file_path: Path to the YAML file to load.
42 Returns:
43 A new instance populated from the file.
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))
52 @classmethod
53 def from_config(cls, config: dict[str, Any]) -> Self:
54 """Create a model instance from a configuration dictionary.
56 Args:
57 config: Dictionary containing model configuration.
59 Returns:
60 A new instance populated from the dictionary.
61 """
62 ...
64 @property
65 def config(self) -> dict[str, Any]:
66 """Return the model's current state as a configuration dictionary."""
67 ...
69 def to_yaml(self, file_path: Path) -> None:
70 """Save the model to a YAML file.
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)
81_T = TypeVar("_T")
84def read_yaml(path: Path) -> dict[str, Any]:
85 """Open *path*, parse YAML, and return the config dict.
87 Args:
88 path: Path to the YAML file to load.
90 Returns:
91 Parsed YAML content as a dictionary.
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
108def load_model(cls: type[_T], path: Path) -> _T:
109 """Load a YAML-serializable model from *path*.
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.
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.
121 Returns:
122 An instance of *cls* populated from *path*.
124 Raises:
125 TypeError: If *cls* does not implement ``from_yaml``.
127 Example::
129 from rhiza.models._base import load_model
130 from rhiza.models.lock import TemplateLock
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]