Coverage for src/CSET/__init__.py: 97%

113 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-19 12:33 +0000

1# © Crown copyright, Met Office (2022-2026) 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. 

14 

15"""CSET: Community Seamless Evaluation Toolkit.""" 

16 

17import argparse 

18import logging 

19import os 

20import sys 

21from importlib.metadata import version 

22from pathlib import Path 

23 

24from CSET._common import ArgumentError 

25from CSET._common import sample_data_path as sample_data_path 

26 

27logger = logging.getLogger(__name__) 

28 

29 

30def main(raw_cli_args: list[str] = sys.argv): 

31 """CLI entrypoint. 

32 

33 Handles argument parsing, setting up logging, top level error capturing, 

34 and execution of the desired subcommand. 

35 """ 

36 parser = setup_argument_parser() 

37 args, unparsed_args = parser.parse_known_args(raw_cli_args[1:]) 

38 

39 setup_logging(args.verbose) 

40 

41 if args.subparser is None: 

42 print("Please choose a command.", file=sys.stderr) 

43 parser.print_usage() 

44 sys.exit(127) 

45 

46 try: 

47 # Execute the specified subcommand. 

48 args.func(args, unparsed_args) 

49 except ArgumentError as err: 

50 # Error message for when needed template variables are missing. 

51 print(err, file=sys.stderr) 

52 parser.print_usage() 

53 sys.exit(127) 

54 except Exception as err: 

55 # Provide slightly nicer error messages for unhandled exceptions. 

56 print(err, file=sys.stderr) 

57 # Display the time and full traceback when debug logging. 

58 logger.debug("An unhandled exception occurred.") 

59 if logger.isEnabledFor(logging.DEBUG): 

60 raise 

61 sys.exit(1) 

62 

63 

64def setup_argument_parser() -> argparse.ArgumentParser: 

65 """Create argument parser for CSET CLI.""" 

66 parser = argparse.ArgumentParser( 

67 prog="cset", description="Community Seamless Evaluation Toolkit" 

68 ) 

69 parser.add_argument( 

70 "-v", 

71 "--verbose", 

72 action="count", 

73 default=0, 

74 help="increase output verbosity, may be specified multiple times", 

75 ) 

76 parser.add_argument( 

77 "--version", action="version", version=f"CSET v{version('CSET')}" 

78 ) 

79 

80 # https://docs.python.org/3/library/argparse.html#sub-commands 

81 subparsers = parser.add_subparsers(title="subcommands", dest="subparser") 

82 

83 # Run operator chain 

84 parser_bake = subparsers.add_parser("bake", help="run steps from a recipe file") 

85 parser_bake.add_argument( 

86 "-i", 

87 "--input-dir", 

88 type=str, 

89 action="extend", 

90 nargs="+", 

91 help="Alternate way to set the INPUT_PATHS recipe variable", 

92 ) 

93 parser_bake.add_argument( 

94 "-o", 

95 "--output-dir", 

96 type=Path, 

97 required=True, 

98 help="directory to write output into", 

99 ) 

100 parser_bake.add_argument( 

101 "-r", 

102 "--recipe", 

103 type=Path, 

104 required=True, 

105 help="recipe file to read", 

106 ) 

107 parser_bake.add_argument( 

108 "-s", "--style-file", type=Path, help="colour bar definition to use" 

109 ) 

110 parser_bake.add_argument( 

111 "--plot-resolution", type=int, help="plotting resolution in dpi" 

112 ) 

113 parser_bake.add_argument( 

114 "--skip-write", action="store_true", help="Skip saving processed output" 

115 ) 

116 parser_bake.set_defaults(func=_bake_command) 

117 

118 parser_probe = subparsers.add_parser( 

119 "probe", 

120 help="list variables in input data understood by CSET", 

121 ) 

122 parser_probe.add_argument( 

123 "file_paths", 

124 type=str, 

125 nargs="+", 

126 help="input files, directories, or glob patterns", 

127 ) 

128 parser_probe.set_defaults(func=_probe_command) 

129 

130 parser_graph = subparsers.add_parser("graph", help="visualise a recipe file") 

131 parser_graph.add_argument( 

132 "-d", 

133 "--details", 

134 action="store_true", 

135 help="include operator arguments in output", 

136 ) 

137 parser_graph.add_argument( 

138 "-o", 

139 "--output-path", 

140 type=Path, 

141 nargs="?", 

142 help="persistent file to save the graph. Otherwise the file is opened", 

143 default=None, 

144 ) 

145 parser_graph.add_argument( 

146 "-r", 

147 "--recipe", 

148 type=Path, 

149 required=True, 

150 help="recipe file to read", 

151 ) 

152 parser_graph.set_defaults(func=_graph_command) 

153 

154 parser_cookbook = subparsers.add_parser( 

155 "cookbook", help="unpack included recipes to a folder" 

156 ) 

157 parser_cookbook.add_argument( 

158 "-d", 

159 "--details", 

160 action="store_true", 

161 help="list available recipes. Supplied recipes are detailed", 

162 ) 

163 parser_cookbook.add_argument( 

164 "-o", 

165 "--output-dir", 

166 type=Path, 

167 help="directory to save recipes. If omitted uses $PWD", 

168 default=Path.cwd(), 

169 ) 

170 parser_cookbook.add_argument( 

171 "recipe", 

172 type=str, 

173 nargs="?", 

174 help="recipe to output or detail", 

175 default="", 

176 ) 

177 parser_cookbook.set_defaults(func=_cookbook_command) 

178 

179 parser_extract_workflow = subparsers.add_parser( 

180 "extract-workflow", help="extract the CSET cylc workflow" 

181 ) 

182 parser_extract_workflow.add_argument( 

183 "location", type=Path, help="directory to save workflow into" 

184 ) 

185 parser_extract_workflow.add_argument( 

186 "--restricted", 

187 action="store_true", 

188 help="install restricted site-specific files during extraction", 

189 ) 

190 parser_extract_workflow.add_argument( 

191 "--restricted-url", 

192 type=str, 

193 help=( 

194 "Alternative Git URL to fetch the restricted files from. " 

195 "If omitted, defaults to trying to clone first from " 

196 "'localmirrors:', then from GitHub via SSH and HTTPS." 

197 ), 

198 ) 

199 parser_extract_workflow.set_defaults(func=_extract_workflow_command) 

200 

201 parser_install_restricted_files = subparsers.add_parser( 

202 "install-restricted-files", 

203 help="download and install restricted site-specific files for the CSET cylc workflow", 

204 ) 

205 parser_install_restricted_files.add_argument( 

206 "location", type=Path, help="directory containing workflow" 

207 ) 

208 parser_install_restricted_files.add_argument( 

209 "--restricted-url", 

210 type=str, 

211 help=( 

212 "Alternative Git URL to fetch the restricted files from. " 

213 "If omitted, defaults to trying to clone first from " 

214 "'localmirrors:', then from GitHub via SSH and HTTPS." 

215 ), 

216 ) 

217 parser_install_restricted_files.set_defaults(func=_install_restricted_files_command) 

218 

219 return parser 

220 

221 

222def setup_logging(verbosity: int): 

223 """Configure logging level, format and output stream. 

224 

225 Level is based on verbose argument and the LOGLEVEL environment variable. 

226 """ 

227 logging.captureWarnings(True) 

228 

229 # Calculate logging level. 

230 # Level from CLI flags. 

231 if verbosity >= 2: 

232 cli_loglevel = logging.DEBUG 

233 elif verbosity == 1: 

234 cli_loglevel = logging.INFO 

235 else: 

236 cli_loglevel = logging.WARNING 

237 

238 # Level from $LOGLEVEL environment variable. 

239 env_loglevel = logging.getLevelNamesMapping().get( 

240 os.getenv("LOGLEVEL", ""), logging.ERROR 

241 ) 

242 

243 # Logging verbosity is the most verbose of CLI and environment setting. 

244 loglevel = min(cli_loglevel, env_loglevel) 

245 

246 # Configure the root logger. 

247 logger = logging.getLogger() 

248 # Set logging level. 

249 logger.setLevel(loglevel) 

250 

251 # Suppress matplotlib's verbose debug output. 

252 logging.getLogger("matplotlib").setLevel(logging.WARNING) 

253 

254 stderr_log = logging.StreamHandler(stream=sys.stdout) 

255 stderr_log.setFormatter( 

256 logging.Formatter("%(asctime)s %(name)s %(levelname)s %(message)s") 

257 ) 

258 logger.addHandler(stderr_log) 

259 

260 

261def _bake_command(args, unparsed_args): 

262 from CSET._common import parse_recipe, parse_variable_options 

263 from CSET.operators import execute_recipe 

264 

265 recipe_variables = parse_variable_options(unparsed_args, args.input_dir) 

266 recipe = parse_recipe(args.recipe, recipe_variables) 

267 execute_recipe( 

268 recipe, 

269 args.output_dir, 

270 args.style_file, 

271 args.plot_resolution, 

272 args.skip_write, 

273 ) 

274 

275 

276def _probe_command(args, unparsed_args): 

277 from CSET.operators.read import _probe_variables 

278 

279 variables = _probe_variables(args.file_paths) 

280 

281 print("Valid variables...") 

282 print([name for name in variables]) 

283 

284 

285def _graph_command(args, unparsed_args): 

286 from CSET.graph import save_graph 

287 

288 save_graph( 

289 args.recipe, 

290 args.output_path, 

291 auto_open=not args.output_path, 

292 detailed=args.details, 

293 ) 

294 

295 

296def _cookbook_command(args, unparsed_args): 

297 from CSET.recipes import detail_recipe, list_available_recipes, unpack_recipe 

298 

299 if args.recipe: 

300 if args.details: 

301 detail_recipe(args.recipe) 

302 else: 

303 try: 

304 unpack_recipe(args.output_dir, args.recipe) 

305 except FileNotFoundError: 

306 logger.error("Recipe %s does not exist.", args.recipe) 

307 sys.exit(1) 

308 else: 

309 list_available_recipes() 

310 

311 

312def _extract_workflow_command(args, unparsed_args): 

313 from CSET.extract_workflow import install_restricted_files, install_workflow 

314 

315 workflow_dir = install_workflow(args.location) 

316 if args.restricted: 

317 install_restricted_files(workflow_dir, args.restricted_url) 

318 

319 

320def _install_restricted_files_command(args, unparsed_args): 

321 from CSET.extract_workflow import install_restricted_files 

322 

323 install_restricted_files(args.location, args.restricted_url)