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

104 statements  

« prev     ^ index     » next       coverage.py v7.15.0, created at 2026-07-08 11:00 +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 

25 

26logger = logging.getLogger(__name__) 

27 

28 

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

30 """CLI entrypoint. 

31 

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

33 and execution of the desired subcommand. 

34 """ 

35 parser = setup_argument_parser() 

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

37 

38 setup_logging(args.verbose) 

39 

40 if args.subparser is None: 

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

42 parser.print_usage() 

43 sys.exit(127) 

44 

45 try: 

46 # Execute the specified subcommand. 

47 args.func(args, unparsed_args) 

48 except ArgumentError as err: 

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

50 print(err, file=sys.stderr) 

51 parser.print_usage() 

52 sys.exit(127) 

53 except Exception as err: 

54 # Provide slightly nicer error messages for unhandled exceptions. 

55 print(err, file=sys.stderr) 

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

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

58 if logger.isEnabledFor(logging.DEBUG): 

59 raise 

60 sys.exit(1) 

61 

62 

63def setup_argument_parser() -> argparse.ArgumentParser: 

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

65 parser = argparse.ArgumentParser( 

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

67 ) 

68 parser.add_argument( 

69 "-v", 

70 "--verbose", 

71 action="count", 

72 default=0, 

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

74 ) 

75 parser.add_argument( 

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

77 ) 

78 

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

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

81 

82 # Run operator chain 

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

84 parser_bake.add_argument( 

85 "-i", 

86 "--input-dir", 

87 type=str, 

88 action="extend", 

89 nargs="+", 

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

91 ) 

92 parser_bake.add_argument( 

93 "-o", 

94 "--output-dir", 

95 type=Path, 

96 required=True, 

97 help="directory to write output into", 

98 ) 

99 parser_bake.add_argument( 

100 "-r", 

101 "--recipe", 

102 type=Path, 

103 required=True, 

104 help="recipe file to read", 

105 ) 

106 parser_bake.add_argument( 

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

108 ) 

109 parser_bake.add_argument( 

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

111 ) 

112 parser_bake.add_argument( 

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

114 ) 

115 parser_bake.set_defaults(func=_bake_command) 

116 

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

118 parser_graph.add_argument( 

119 "-d", 

120 "--details", 

121 action="store_true", 

122 help="include operator arguments in output", 

123 ) 

124 parser_graph.add_argument( 

125 "-o", 

126 "--output-path", 

127 type=Path, 

128 nargs="?", 

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

130 default=None, 

131 ) 

132 parser_graph.add_argument( 

133 "-r", 

134 "--recipe", 

135 type=Path, 

136 required=True, 

137 help="recipe file to read", 

138 ) 

139 parser_graph.set_defaults(func=_graph_command) 

140 

141 parser_cookbook = subparsers.add_parser( 

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

143 ) 

144 parser_cookbook.add_argument( 

145 "-d", 

146 "--details", 

147 action="store_true", 

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

149 ) 

150 parser_cookbook.add_argument( 

151 "-o", 

152 "--output-dir", 

153 type=Path, 

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

155 default=Path.cwd(), 

156 ) 

157 parser_cookbook.add_argument( 

158 "recipe", 

159 type=str, 

160 nargs="?", 

161 help="recipe to output or detail", 

162 default="", 

163 ) 

164 parser_cookbook.set_defaults(func=_cookbook_command) 

165 

166 parser_extract_workflow = subparsers.add_parser( 

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

168 ) 

169 parser_extract_workflow.add_argument( 

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

171 ) 

172 parser_extract_workflow.add_argument( 

173 "--restricted", 

174 action="store_true", 

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

176 ) 

177 parser_extract_workflow.add_argument( 

178 "--restricted-url", 

179 type=str, 

180 help=( 

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

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

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

184 ), 

185 ) 

186 parser_extract_workflow.set_defaults(func=_extract_workflow_command) 

187 

188 parser_install_restricted_files = subparsers.add_parser( 

189 "install-restricted-files", 

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

191 ) 

192 parser_install_restricted_files.add_argument( 

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

194 ) 

195 parser_install_restricted_files.add_argument( 

196 "--restricted-url", 

197 type=str, 

198 help=( 

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

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

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

202 ), 

203 ) 

204 parser_install_restricted_files.set_defaults(func=_install_restricted_files_command) 

205 

206 return parser 

207 

208 

209def setup_logging(verbosity: int): 

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

211 

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

213 """ 

214 logging.captureWarnings(True) 

215 

216 # Calculate logging level. 

217 # Level from CLI flags. 

218 if verbosity >= 2: 

219 cli_loglevel = logging.DEBUG 

220 elif verbosity == 1: 

221 cli_loglevel = logging.INFO 

222 else: 

223 cli_loglevel = logging.WARNING 

224 

225 # Level from $LOGLEVEL environment variable. 

226 env_loglevel = logging.getLevelNamesMapping().get( 

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

228 ) 

229 

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

231 loglevel = min(cli_loglevel, env_loglevel) 

232 

233 # Configure the root logger. 

234 logger = logging.getLogger() 

235 # Set logging level. 

236 logger.setLevel(loglevel) 

237 

238 # Suppress matplotlib's verbose debug output. 

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

240 

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

242 stderr_log.setFormatter( 

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

244 ) 

245 logger.addHandler(stderr_log) 

246 

247 

248def _bake_command(args, unparsed_args): 

249 from CSET._common import parse_recipe, parse_variable_options 

250 from CSET.operators import execute_recipe 

251 

252 recipe_variables = parse_variable_options(unparsed_args, args.input_dir) 

253 recipe = parse_recipe(args.recipe, recipe_variables) 

254 execute_recipe( 

255 recipe, 

256 args.output_dir, 

257 args.style_file, 

258 args.plot_resolution, 

259 args.skip_write, 

260 ) 

261 

262 

263def _graph_command(args, unparsed_args): 

264 from CSET.graph import save_graph 

265 

266 save_graph( 

267 args.recipe, 

268 args.output_path, 

269 auto_open=not args.output_path, 

270 detailed=args.details, 

271 ) 

272 

273 

274def _cookbook_command(args, unparsed_args): 

275 from CSET.recipes import detail_recipe, list_available_recipes, unpack_recipe 

276 

277 if args.recipe: 

278 if args.details: 

279 detail_recipe(args.recipe) 

280 else: 

281 try: 

282 unpack_recipe(args.output_dir, args.recipe) 

283 except FileNotFoundError: 

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

285 sys.exit(1) 

286 else: 

287 list_available_recipes() 

288 

289 

290def _extract_workflow_command(args, unparsed_args): 

291 from CSET.extract_workflow import install_restricted_files, install_workflow 

292 

293 workflow_dir = install_workflow(args.location) 

294 if args.restricted: 294 ↛ 295line 294 didn't jump to line 295 because the condition on line 294 was never true

295 install_restricted_files(workflow_dir, args.restricted_url) 

296 

297 

298def _install_restricted_files_command(args, unparsed_args): 

299 from CSET.extract_workflow import install_restricted_files 

300 

301 install_restricted_files(args.location, args.restricted_url)