Coverage for src/CSET/recipes/__init__.py: 100%
104 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-24 13:03 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-24 13:03 +0000
1# © Crown copyright, Met Office (2022-2025) and CSET contributors.
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
15"""Operations on recipes."""
17import hashlib
18import importlib.resources
19import logging
20from collections.abc import Iterator
21from io import StringIO
22from pathlib import Path
23from typing import Any
25from ruamel.yaml import YAML
27from CSET._common import parse_recipe, slugify
28from CSET.cset_workflow.lib.python.jinja_utils import get_models as get_models
30logger = logging.getLogger(__name__)
33def _recipe_files_in_tree(
34 recipe_name: str | None = None, input_dir: Path | None = None
35) -> Iterator[Path]:
36 """Yield recipe file Paths matching the recipe name."""
37 if input_dir is None:
38 input_dir = importlib.resources.files()
39 for file in input_dir.iterdir():
40 logger.debug("Testing %s", file)
41 if (
42 (recipe_name is None or recipe_name == file.name)
43 and file.is_file()
44 and file.suffix == ".yaml"
45 ):
46 yield file
47 elif file.is_dir() and file.name[0] != "_": # Excludes __pycache__
48 yield from _recipe_files_in_tree(recipe_name, file)
51def _get_recipe_file(recipe_name: str, input_dir: Path | None = None) -> Path:
52 """Return a Path to the recipe file."""
53 if input_dir is None:
54 input_dir = importlib.resources.files()
55 file = input_dir / recipe_name
56 logger.debug("Getting recipe: %s", file)
57 if not file.is_file():
58 raise FileNotFoundError("Recipe file does not exist.", recipe_name)
59 return file
62def unpack_recipe(recipe_dir: Path, recipe_name: str) -> None:
63 """
64 Unpacks recipes files into a directory, creating it if it doesn't exist.
66 Parameters
67 ----------
68 recipe_dir: Path
69 Path to a directory into which to unpack the recipe files.
70 recipe_name: str
71 Name of recipe to unpack.
73 Raises
74 ------
75 FileExistsError
76 If recipe_dir already exists, and is not a directory.
78 OSError
79 If recipe_dir cannot be created, such as insufficient permissions, or
80 lack of space.
81 """
82 recipe_dir.mkdir(parents=True, exist_ok=True)
83 output_file = recipe_dir / recipe_name
84 logger.debug("Saving recipe to %s", output_file)
85 if output_file.exists():
86 logger.debug("%s already exists in target directory, skipping.", recipe_name)
87 return
88 logger.info("Unpacking %s to %s", recipe_name, output_file)
89 try:
90 file = _get_recipe_file(next(_recipe_files_in_tree(recipe_name)))
91 except StopIteration as err:
92 raise FileNotFoundError("Recipe not found.") from err
93 output_file.write_bytes(file.read_bytes())
96def list_available_recipes() -> None:
97 """List available recipes to stdout."""
98 print("Available recipes:")
99 for file in _recipe_files_in_tree():
100 print(f"\t{file.name}")
103def detail_recipe(recipe_name: str) -> None:
104 """Detail the recipe to stdout.
106 If multiple recipes match the given name they will all be displayed.
108 Parameters
109 ----------
110 recipe_name: str
111 Partial match for the recipe name.
112 """
113 for file in _recipe_files_in_tree(recipe_name):
114 with YAML(typ="safe", pure=True) as yaml:
115 recipe = yaml.load(file)
116 print(f"\n\t{file.name}\n\t{''.join('─' * len(file.name))}\n")
117 print(recipe.get("description"))
120class RawRecipe:
121 """A recipe to be parbaked.
123 Parameters
124 ----------
125 recipe: str
126 Name of the recipe file.
127 model_ids: int | list[int]
128 Model IDs to set the input paths for. Matches the corresponding workflow
129 model IDs.
130 variables: dict[str, Any] aggregation: bool
131 Recipe variables to be inserted into $VAR placeholders in the recipe.
132 aggregation: bool
133 Whether this is an aggregation recipe or just a single case.
135 Returns
136 -------
137 RawRecipe
138 """
140 recipe: str
141 model_ids: list[int]
142 variables: dict[str, Any]
143 aggregation: bool
145 def __init__(
146 self,
147 recipe: str,
148 model_ids: int | list[int],
149 variables: dict[str, Any],
150 aggregation: bool,
151 ) -> None:
152 self.recipe = recipe
153 self.model_ids = model_ids if isinstance(model_ids, list) else [model_ids]
154 self.variables = variables
155 self.aggregation = aggregation
157 def __str__(self) -> str:
158 """Return str(self).
160 Examples
161 --------
162 >>> print(raw_recipe)
163 generic_surface_spatial_plot_sequence.yaml (model 1)
164 VARNAME air_temperature
165 MODEL_NAME Model A
166 METHOD SEQ
167 SUBAREA_TYPE None
168 SUBAREA_EXTENT None
169 """
170 recipe = self.recipe if self.recipe else "<unknown>"
171 plural = "s" if len(self.model_ids) > 1 else ""
172 ids = " ".join(str(m) for m in self.model_ids)
173 aggregation = ", Aggregation" if self.aggregation else ""
174 pad = max([0] + [len(k) for k in self.variables.keys()])
175 variables = "".join(f"\n\t{k:<{pad}} {v}" for k, v in self.variables.items())
176 return f"{recipe} (model{plural} {ids}{aggregation}){variables}"
178 def __eq__(self, value: object) -> bool:
179 """Return self==value."""
180 if isinstance(value, self.__class__):
181 return (
182 self.recipe == value.recipe
183 and self.model_ids == value.model_ids
184 and self.variables == value.variables
185 and self.aggregation == value.aggregation
186 )
187 return NotImplemented
189 def parbake(self, ROSE_DATAC: Path, SHARE_DIR: Path) -> None:
190 """Pre-process recipe to bake in all variables.
192 Parameters
193 ----------
194 ROSE_DATAC: Path
195 Workflow shared per-cycle data location.
196 SHARE_DIR: Path
197 Workflow shared data location.
198 """
199 # Ready recipe file to disk.
200 unpack_recipe(Path.cwd(), self.recipe)
202 # Collect configuration from environment.
203 if self.aggregation:
204 # Construct the location for the recipe.
205 recipe_dir = ROSE_DATAC / "aggregation_recipes"
206 # Construct the input data directories for the cycle.
207 data_dirs = [
208 SHARE_DIR / f"cycle/*/data/{model_id}" for model_id in self.model_ids
209 ]
210 else:
211 recipe_dir = ROSE_DATAC / "recipes"
212 data_dirs = [ROSE_DATAC / f"data/{model_id}" for model_id in self.model_ids]
214 # Ensure recipe dir exists.
215 recipe_dir.mkdir(parents=True, exist_ok=True)
217 # Add input paths to recipe variables.
218 self.variables["INPUT_PATHS"] = data_dirs
220 # Parbake this recipe.
221 recipe = parse_recipe(Path(self.recipe), self.variables)
223 # Add variables as extra metadata to filter on.
224 for key, value in self.variables.items():
225 # Don't overwrite existing keys.
226 if key != "INPUT_PATHS" and key not in recipe:
227 recipe[key] = value
229 # Serialise into memory, as we use the serialised value twice.
230 with StringIO() as s:
231 with YAML(pure=True, output=s) as yaml:
232 yaml.dump(recipe)
233 serialised_recipe = s.getvalue().encode()
234 # Include shortened hash in filename to avoid collisions between recipes
235 # with the same title.
236 digest = hashlib.sha256(serialised_recipe).hexdigest()
237 output_filename = recipe_dir / f"{slugify(recipe['title'])}_{digest[:12]}.yaml"
238 # Save into recipe_dir.
239 with open(output_filename, "wb") as fp:
240 fp.write(serialised_recipe)
243class Config:
244 """Namespace for easy access to configuration values.
246 A namespace for easy access to configuration values (via config.variable),
247 where undefined attributes return an empty list. An empty list evaluates to
248 False in boolean contexts and can be safely iterated over, so it acts as an
249 effective unset value.
251 Parameters
252 ----------
253 config: dict
254 Configuration key-value pairs.
256 Example
257 -------
258 >>> conf = Config({"key": "value"})
259 >>> conf.key
260 'value'
261 >>> conf.missing
262 []
263 """
265 d: dict
267 def __init__(self, config: dict) -> None:
268 self.d = config
270 def __getattr__(self, name: str):
271 """Return an empty list for missing names."""
272 return self.d.get(name, [])
274 def asdict(self) -> dict:
275 """Return config as a dictionary."""
276 return self.d
279def load_recipes(variables: dict[str, Any]) -> Iterator[RawRecipe]:
280 """Load recipes enabled by configuration.
282 Recipes are loaded using all loaders (python modules) in CSET.loaders. Each
283 of these loaders must define a function with the signature `load(conf: dict)
284 -> Iterator[RawRecipe]`, which will be called with `variables`.
286 A minimal example can be found in `CSET.loaders.test`.
288 Parameters
289 ----------
290 variables: dict[str, Any]
291 Workflow configuration from ROSE_SUITE_VARIABLES.
293 Returns
294 -------
295 Iterator[RawRecipe]
296 Configured recipes.
298 Raises
299 ------
300 AttributeError
301 When a loader doesn't provide a `load` function.
302 """
303 # Import here to avoid circular import.
304 import CSET.loaders
306 config = Config(variables)
307 for loader in CSET.loaders.__all__:
308 logger.info("Loading recipes from %s", loader)
309 module = getattr(CSET.loaders, loader)
310 yield from module.load(config)