Coverage for src/CSET/_common.py: 100%

115 statements  

« prev     ^ index     » next       coverage.py v7.5.4, created at 2024-07-01 15:05 +0000

1# Copyright 2022-2024 Met Office and 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. 

14 

15"""Common functionality used across CSET.""" 

16 

17import io 

18import json 

19import logging 

20import re 

21from collections.abc import Iterable 

22from pathlib import Path 

23from typing import Union 

24 

25import ruamel.yaml 

26 

27 

28class ArgumentError(ValueError): 

29 """Provided arguments are not understood.""" 

30 

31 

32def parse_recipe(recipe_yaml: Union[Path, str], variables: dict = None): 

33 """Parse a recipe into a python dictionary. 

34 

35 Parameters 

36 ---------- 

37 recipe_yaml: Path | str 

38 Path to recipe file, or the recipe YAML directly. 

39 variables: dict 

40 Dictionary of recipe variables. If None templating is not attempted. 

41 

42 Returns 

43 ------- 

44 recipe: dict 

45 The recipe as a python dictionary. 

46 

47 Raises 

48 ------ 

49 ValueError 

50 If the recipe is invalid. E.g. invalid YAML, missing any steps, etc. 

51 TypeError 

52 If recipe_yaml isn't a Path or string. 

53 KeyError 

54 If needed recipe variables are not supplied. 

55 

56 Examples 

57 -------- 

58 >>> CSET._common.parse_recipe(Path("myrecipe.yaml")) 

59 {'parallel': [{'operator': 'misc.noop'}]} 

60 """ 

61 # Ensure recipe_yaml is something the YAML parser can read. 

62 if isinstance(recipe_yaml, str): 

63 recipe_yaml = io.StringIO(recipe_yaml) 

64 elif not isinstance(recipe_yaml, Path): 

65 raise TypeError("recipe_yaml must be a str or Path.") 

66 

67 # Parse the recipe YAML. 

68 with ruamel.yaml.YAML(typ="safe", pure=True) as yaml: 

69 try: 

70 recipe = yaml.load(recipe_yaml) 

71 except ruamel.yaml.parser.ParserError as err: 

72 raise ValueError("ParserError: Invalid YAML") from err 

73 

74 logging.debug(recipe) 

75 check_recipe_has_steps(recipe) 

76 

77 if variables is not None: 

78 logging.debug("Recipe variables: %s", variables) 

79 recipe = template_variables(recipe, variables) 

80 

81 return recipe 

82 

83 

84def check_recipe_has_steps(recipe: dict): 

85 """Check a recipe has the minimum required steps. 

86 

87 Checking that the recipe actually has some steps, and providing helpful 

88 error messages otherwise. We must have at least a parallel step, as that 

89 reads the raw data. 

90 

91 Parameters 

92 ---------- 

93 recipe: dict 

94 The recipe as a python dictionary. 

95 

96 Raises 

97 ------ 

98 ValueError 

99 If the recipe is invalid. E.g. invalid YAML, missing any steps, etc. 

100 TypeError 

101 If recipe isn't a dict. 

102 KeyError 

103 If needed recipe variables are not supplied. 

104 """ 

105 parallel_steps_key = "parallel" 

106 if not isinstance(recipe, dict): 

107 raise TypeError("Recipe must contain a mapping.") 

108 if "parallel" not in recipe: 

109 raise ValueError("Recipe must contain a 'parallel' key.") 

110 try: 

111 if len(recipe[parallel_steps_key]) < 1: 

112 raise ValueError("Recipe must have at least 1 parallel step.") 

113 except TypeError as err: 

114 raise ValueError("'parallel' key must contain a sequence of steps.") from err 

115 

116 

117def slugify(s: str) -> str: 

118 """Turn a string into a version that can be used everywhere. 

119 

120 The resultant string will only consist of a-z, 0-9, dots, dashes, and 

121 underscores. 

122 """ 

123 return re.sub(r"[^a-z0-9\._-]+", "_", s.casefold()).strip("_") 

124 

125 

126def get_recipe_metadata() -> dict: 

127 """Get the metadata of the running recipe.""" 

128 try: 

129 with open("meta.json", "rt", encoding="UTF-8") as fp: 

130 return json.load(fp) 

131 except FileNotFoundError: 

132 meta = {} 

133 with open("meta.json", "wt", encoding="UTF-8") as fp: 

134 json.dump(meta, fp) 

135 return {} 

136 

137 

138def parse_variable_options(arguments: list[str]) -> dict: 

139 """Parse a list of arguments into a dictionary of variables. 

140 

141 The variable name arguments start with two hyphen-minus (`--`), consisting 

142 of only capital letters (`A`-`Z`) and underscores (`_`). While the variable 

143 name is restricted, the value of the variable can be any string. 

144 

145 Parameters 

146 ---------- 

147 arguments: list[str] 

148 List of arguments, e.g: `["--LEVEL", "2", "--STASH=m01s01i001"]` 

149 

150 Returns 

151 ------- 

152 recipe_variables: dict 

153 Dictionary keyed with the variable names. 

154 

155 Raises 

156 ------ 

157 ValueError 

158 If any arguments cannot be parsed. 

159 """ 

160 recipe_variables = {} 

161 i = 0 

162 while i < len(arguments): 

163 if re.match(r"^--[A-Z_]+=.*$", arguments[i]): 

164 key, value = arguments[i].split("=", 1) 

165 elif re.match(r"^--[A-Z_]+$", arguments[i]): 

166 try: 

167 key = arguments[i].strip("-") 

168 value = arguments[i + 1] 

169 except IndexError as err: 

170 raise ArgumentError(f"No value for variable {arguments[i]}") from err 

171 i += 1 

172 else: 

173 raise ArgumentError(f"Unknown argument: {arguments[i]}") 

174 try: 

175 recipe_variables[key.strip("-")] = json.loads(value) 

176 except json.JSONDecodeError: 

177 recipe_variables[key.strip("-")] = value 

178 i += 1 

179 return recipe_variables 

180 

181 

182def template_variables(recipe: Union[dict, list], variables: dict) -> dict: 

183 """Insert variables into recipe. 

184 

185 Parameters 

186 ---------- 

187 recipe: dict | list 

188 The recipe as a python dictionary. It is updated in-place. 

189 variables: dict 

190 Dictionary of variables for the recipe. 

191 

192 Returns 

193 ------- 

194 recipe: dict 

195 Filled recipe as a python dictionary. 

196 

197 Raises 

198 ------ 

199 KeyError 

200 If needed recipe variables are not supplied. 

201 """ 

202 if isinstance(recipe, dict): 

203 index = recipe.keys() 

204 elif isinstance(recipe, list): 

205 # We have to handle lists for when we have one inside a recipe. 

206 index = range(len(recipe)) 

207 else: 

208 raise TypeError("recipe must be a dict or list.", recipe) 

209 

210 for i in index: 

211 if isinstance(recipe[i], (dict, list)): 

212 recipe[i] = template_variables(recipe[i], variables) 

213 elif isinstance(recipe[i], str): 

214 recipe[i] = replace_template_variable(recipe[i], variables) 

215 return recipe 

216 

217 

218def replace_template_variable(s: str, variables): 

219 """Fill all variable placeholders in the string.""" 

220 for var_name, var_value in variables.items(): 

221 placeholder = f"${var_name}" 

222 # If the value is just the placeholder we directly overwrite it 

223 # to keep the value type. 

224 if s == placeholder: 

225 s = var_value 

226 break 

227 else: 

228 s = s.replace(placeholder, str(var_value)) 

229 if isinstance(s, str) and re.match(r"^.*\$[A-Z_].*", s): 

230 raise KeyError("Variable without a value.", s) 

231 return s 

232 

233 

234################################################################################ 

235# Templating code taken from the simple_template package under the 0BSD licence. 

236# Original at https://github.com/Fraetor/simple_template 

237################################################################################ 

238 

239 

240class TemplateError(KeyError): 

241 """Rendering a template failed due a placeholder without a value.""" 

242 

243 

244def render(template: str, /, **variables) -> str: 

245 """Render the template with the provided variables. 

246 

247 The template should contain placeholders that will be replaced. These 

248 placeholders consist of the placeholder name within double curly braces. The 

249 name of the placeholder should be a valid python identifier. Whitespace 

250 between the braces and the name is ignored. E.g.: `{{ placeholder_name }}` 

251 

252 An exception will be raised if there are placeholders without corresponding 

253 values. It is acceptable to provide unused values; they will be ignored. 

254 

255 Parameters 

256 ---------- 

257 template: str 

258 Template to fill with variables. 

259 

260 **variables: Any 

261 Keyword arguments for the placeholder values. The argument name should 

262 be the same as the placeholder's name. You can unpack a dictionary of 

263 value with `render(template, **my_dict)`. 

264 

265 Returns 

266 ------- 

267 rendered_template: str 

268 Filled template. 

269 

270 Raises 

271 ------ 

272 TemplateError 

273 Value not given for a placeholder in the template. 

274 TypeError 

275 If the template is not a string, or a variable cannot be casted to a 

276 string. 

277 

278 Examples 

279 -------- 

280 >>> template = "<p>Hello {{myplaceholder}}!</p>" 

281 >>> simple_template.render(template, myplaceholder="World") 

282 "<p>Hello World!</p>" 

283 """ 

284 

285 def isidentifier(s: str): 

286 return s.isidentifier() 

287 

288 def extract_placeholders(): 

289 matches = re.finditer(r"{{\s*([^}]+)\s*}}", template) 

290 unique_names = {match.group(1) for match in matches} 

291 return filter(isidentifier, unique_names) 

292 

293 def substitute_placeholder(name): 

294 try: 

295 value = str(variables[name]) 

296 except KeyError as err: 

297 raise TemplateError("Placeholder missing value", name) from err 

298 pattern = r"{{\s*%s\s*}}" % re.escape(name) 

299 return re.sub(pattern, value, template) 

300 

301 for name in extract_placeholders(): 

302 template = substitute_placeholder(name) 

303 return template 

304 

305 

306def render_file(template_path: str, /, **variables) -> str: 

307 """Render a template directly from a file. 

308 

309 Otherwise the same as `simple_template.render()`. 

310 

311 Examples 

312 -------- 

313 >>> simple_template.render_file("/path/to/template.html", myplaceholder="World") 

314 "<p>Hello World!</p>" 

315 """ 

316 with open(template_path, "rt", encoding="UTF-8") as fp: 

317 template = fp.read() 

318 return render(template, **variables) 

319 

320 

321def iter_maybe(thing) -> Iterable: 

322 """Ensure thing is Iterable. Strings count as atoms.""" 

323 if isinstance(thing, Iterable) and not isinstance(thing, str): 

324 return thing 

325 return (thing,)