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

105 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-07 15:12 +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_graph = subparsers.add_parser("graph", help="visualise a recipe file") 

119 parser_graph.add_argument( 

120 "-d", 

121 "--details", 

122 action="store_true", 

123 help="include operator arguments in output", 

124 ) 

125 parser_graph.add_argument( 

126 "-o", 

127 "--output-path", 

128 type=Path, 

129 nargs="?", 

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

131 default=None, 

132 ) 

133 parser_graph.add_argument( 

134 "-r", 

135 "--recipe", 

136 type=Path, 

137 required=True, 

138 help="recipe file to read", 

139 ) 

140 parser_graph.set_defaults(func=_graph_command) 

141 

142 parser_cookbook = subparsers.add_parser( 

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

144 ) 

145 parser_cookbook.add_argument( 

146 "-d", 

147 "--details", 

148 action="store_true", 

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

150 ) 

151 parser_cookbook.add_argument( 

152 "-o", 

153 "--output-dir", 

154 type=Path, 

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

156 default=Path.cwd(), 

157 ) 

158 parser_cookbook.add_argument( 

159 "recipe", 

160 type=str, 

161 nargs="?", 

162 help="recipe to output or detail", 

163 default="", 

164 ) 

165 parser_cookbook.set_defaults(func=_cookbook_command) 

166 

167 parser_extract_workflow = subparsers.add_parser( 

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

169 ) 

170 parser_extract_workflow.add_argument( 

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

172 ) 

173 parser_extract_workflow.add_argument( 

174 "--restricted", 

175 action="store_true", 

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

177 ) 

178 parser_extract_workflow.add_argument( 

179 "--restricted-url", 

180 type=str, 

181 help=( 

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

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

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

185 ), 

186 ) 

187 parser_extract_workflow.set_defaults(func=_extract_workflow_command) 

188 

189 parser_install_restricted_files = subparsers.add_parser( 

190 "install-restricted-files", 

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

192 ) 

193 parser_install_restricted_files.add_argument( 

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

195 ) 

196 parser_install_restricted_files.add_argument( 

197 "--restricted-url", 

198 type=str, 

199 help=( 

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

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

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

203 ), 

204 ) 

205 parser_install_restricted_files.set_defaults(func=_install_restricted_files_command) 

206 

207 return parser 

208 

209 

210def setup_logging(verbosity: int): 

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

212 

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

214 """ 

215 logging.captureWarnings(True) 

216 

217 # Calculate logging level. 

218 # Level from CLI flags. 

219 if verbosity >= 2: 

220 cli_loglevel = logging.DEBUG 

221 elif verbosity == 1: 

222 cli_loglevel = logging.INFO 

223 else: 

224 cli_loglevel = logging.WARNING 

225 

226 # Level from $LOGLEVEL environment variable. 

227 env_loglevel = logging.getLevelNamesMapping().get( 

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

229 ) 

230 

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

232 loglevel = min(cli_loglevel, env_loglevel) 

233 

234 # Configure the root logger. 

235 logger = logging.getLogger() 

236 # Set logging level. 

237 logger.setLevel(loglevel) 

238 

239 # Suppress matplotlib's verbose debug output. 

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

241 

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

243 stderr_log.setFormatter( 

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

245 ) 

246 logger.addHandler(stderr_log) 

247 

248 

249def _bake_command(args, unparsed_args): 

250 from CSET._common import parse_recipe, parse_variable_options 

251 from CSET.operators import execute_recipe 

252 

253 recipe_variables = parse_variable_options(unparsed_args, args.input_dir) 

254 recipe = parse_recipe(args.recipe, recipe_variables) 

255 execute_recipe( 

256 recipe, 

257 args.output_dir, 

258 args.style_file, 

259 args.plot_resolution, 

260 args.skip_write, 

261 ) 

262 

263 

264def _graph_command(args, unparsed_args): 

265 from CSET.graph import save_graph 

266 

267 save_graph( 

268 args.recipe, 

269 args.output_path, 

270 auto_open=not args.output_path, 

271 detailed=args.details, 

272 ) 

273 

274 

275def _cookbook_command(args, unparsed_args): 

276 from CSET.recipes import detail_recipe, list_available_recipes, unpack_recipe 

277 

278 if args.recipe: 

279 if args.details: 

280 detail_recipe(args.recipe) 

281 else: 

282 try: 

283 unpack_recipe(args.output_dir, args.recipe) 

284 except FileNotFoundError: 

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

286 sys.exit(1) 

287 else: 

288 list_available_recipes() 

289 

290 

291def _extract_workflow_command(args, unparsed_args): 

292 from CSET.extract_workflow import install_restricted_files, install_workflow 

293 

294 workflow_dir = install_workflow(args.location) 

295 if args.restricted: 

296 install_restricted_files(workflow_dir, args.restricted_url) 

297 

298 

299def _install_restricted_files_command(args, unparsed_args): 

300 from CSET.extract_workflow import install_restricted_files 

301 

302 install_restricted_files(args.location, args.restricted_url)